feat(admin): CRUD menus composes avec slots (P3, mlt 8.4-8.6)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 46s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 52s
CI / auto-merge (pull_request) Successful in 6s
CI / auto-merge (push) Has been skipped
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 46s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 52s
CI / auto-merge (pull_request) Successful in 6s
CI / auto-merge (push) Has been skipped
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.
This commit is contained in:
parent
7d24714d9b
commit
26672b1467
11 changed files with 1724 additions and 3 deletions
235
src/app/Catalogue/MenuRepository.php
Normal file
235
src/app/Catalogue/MenuRepository.php
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Catalogue;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Acces aux donnees du menu compose (sous-domaine Catalogue) : la ligne `menu`,
|
||||
* ses `menu_slot` (slots de composition) et les `menu_slot_option` (produits
|
||||
* eligibles par slot). Suit le pattern de CategoryRepository / ProductRepository.
|
||||
*
|
||||
* Topologie FK (db/migrations/0001) et effet sur la suppression :
|
||||
* - menu.category_id / menu.burger_product_id : RESTRICT (referencent catalogue).
|
||||
* - menu_slot.menu_id : CASCADE (slots possedes par le menu).
|
||||
* - menu_slot_option.menu_slot_id : CASCADE ; .product_id : RESTRICT.
|
||||
* - order_item.menu_id : RESTRICT -> 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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>|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<array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}>
|
||||
*/
|
||||
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<int, array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}> $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<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $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<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $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<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}> $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<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
535
src/app/Controllers/MenuController.php
Normal file
535
src/app/Controllers/MenuController.php
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use PDOException;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\GuardResult;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PinThrottle;
|
||||
use App\Auth\PinVerifier;
|
||||
use App\Catalogue\CategoryRepository;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Catalogue\ProductRepository;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* CRUD des menus composes (P3, mlt 8.4-8.6). Un menu = ligne `menu` + ses
|
||||
* `menu_slot` (slots de composition) + `menu_slot_option` (produits eligibles).
|
||||
*
|
||||
* - create (menu.create) / update (menu.update) : SANS PIN (un menu n'a pas de
|
||||
* vat_rate ; la sensibilite fiscale est au niveau composant -> 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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}>, 2: array<string, string>}
|
||||
*/
|
||||
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<string, string> $errors
|
||||
* @return list<array{name:string, slot_type:string, is_required:int, display_order:int, options:list<int>}>
|
||||
*/
|
||||
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<array{id:int, name:string, slot_type:string, is_required:int, display_order:int, option_product_ids:list<int>}> $slots
|
||||
* @return list<array{name:string, slot_type:string, is_required:int, options:list<int>}>
|
||||
*/
|
||||
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<string, mixed> $values valeurs du menu (re-rendu) ou row trouvee
|
||||
* @param list<array<string, mixed>> $slots slots pre-remplis (structure JSON)
|
||||
* @param array<string, string> $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<string, mixed> $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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ $navClass = static function (string $code, string $current): string {
|
|||
<a href="/admin/dashboard" class="<?= $navClass('dashboard', $active) ?>">Tableau de bord</a>
|
||||
</div>
|
||||
|
||||
<?php if ($can('category.manage') || $can('product.read')): ?>
|
||||
<?php if ($can('category.manage') || $can('product.read') || $can('menu.read')): ?>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Catalogue</div>
|
||||
<?php if ($can('category.manage')): ?>
|
||||
|
|
@ -105,14 +105,17 @@ $navClass = static function (string $code, string $current): string {
|
|||
<?php if ($can('product.read')): ?>
|
||||
<a href="/admin/products" class="<?= $navClass('products', $active) ?>">Produits</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($can('menu.read')): ?>
|
||||
<a href="/admin/menus" class="<?= $navClass('menus', $active) ?>">Menus</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /*
|
||||
Items de nav volontairement absents tant que leur page n'existe pas
|
||||
(un lien vers une route non enregistree renvoie un 404). A reactiver
|
||||
avec leur route respective : Menus (menu.read), Commandes (order.read),
|
||||
Utilisateurs (user.read), Roles (role.manage) -- P3 suite / P4.
|
||||
avec leur route respective : Commandes (order.read), Utilisateurs
|
||||
(user.read), Roles (role.manage) -- P3 suite / P4.
|
||||
*/ ?>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
54
src/app/Views/admin/menus/delete.php
Normal file
54
src/app/Views/admin/menus/delete.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Confirmation de suppression d'un menu (action sensible RG-T13/mlt 8.6) : exige
|
||||
* l'email + le PIN de l'equipier. La suppression cascade vers menu_slot /
|
||||
* menu_slot_option ; bloquee (422) si reference par une commande historique.
|
||||
* Injecte dans admin/layout.php.
|
||||
*
|
||||
* @var int $menuId
|
||||
* @var string $name
|
||||
* @var string|null $error
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$id = (int) ($menuId ?? 0);
|
||||
$menuName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Supprimer un menu</h1>
|
||||
<p class="page-subtitle">Confirmez la suppression de "<?= $menuName ?>".</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<?php if ($errorMessage !== null): ?>
|
||||
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/admin/menus/<?= $id ?>/delete" class="form-card">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<p><small>La suppression est tracee (audit) et retire aussi les slots du menu. Renseignez votre email et votre PIN.</small></p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin_email">Votre email</label>
|
||||
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pin">Votre PIN</label>
|
||||
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Supprimer definitivement</button>
|
||||
<a class="btn btn-secondary" href="/admin/menus">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
138
src/app/Views/admin/menus/form.php
Normal file
138
src/app/Views/admin/menus/form.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Formulaire menu (creation/edition), injecte dans admin/layout.php. Reaffiche
|
||||
* valeurs + erreurs (RG-T18). La composition de slots est geree par le builder
|
||||
* vanilla JS (menu-form.js) qui serialise l'etat dans le champ cache slots_json
|
||||
* a la soumission. Pas de PIN ici (create/update non sensibles, mlt 8.4/8.5).
|
||||
*
|
||||
* @var int $menuId
|
||||
* @var array<int, array<string, mixed>> $categories
|
||||
* @var array<int, array<string, mixed>> $products
|
||||
* @var list<string> $slotTypes
|
||||
* @var array<string, mixed> $values
|
||||
* @var string $slotsJson
|
||||
* @var array<string, string> $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<string, mixed> $vals */
|
||||
$vals = isset($values) && is_array($values) ? $values : [];
|
||||
/** @var array<string, string> $errs */
|
||||
$errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||
/** @var array<int, array<string, mixed>> $cats */
|
||||
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
||||
/** @var array<int, array<string, mixed>> $prods */
|
||||
$prods = isset($products) && is_array($products) ? $products : [];
|
||||
/** @var list<string> $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 : '[]';
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le menu' : 'Nouveau menu' ?></h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card" id="menu-form">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="category_id">Categorie</label>
|
||||
<select class="form-input" id="category_id" name="category_id" required>
|
||||
<option value="">-- choisir --</option>
|
||||
<?php foreach ($cats as $cat): ?>
|
||||
<?php $cid = (string) ($cat['id'] ?? ''); ?>
|
||||
<option value="<?= htmlspecialchars($cid, ENT_QUOTES, 'UTF-8') ?>"<?= $cid === $selectedCat ? ' selected' : '' ?>>
|
||||
<?= htmlspecialchars((string) ($cat['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($err('category_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('category_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="burger_product_id">Burger de base</label>
|
||||
<select class="form-input" id="burger_product_id" name="burger_product_id" required>
|
||||
<option value="">-- choisir --</option>
|
||||
<?php foreach ($prods as $p): ?>
|
||||
<?php $pid = (string) ($p['id'] ?? ''); ?>
|
||||
<option value="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"<?= $pid === $selectedBurger ? ' selected' : '' ?>>
|
||||
<?= htmlspecialchars((string) ($p['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($err('burger_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('burger_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="name">Nom</label>
|
||||
<input class="form-input" type="text" id="name" name="name" maxlength="120" value="<?= $val('name') ?>" required>
|
||||
<?php if ($err('name') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('name'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="price_normal_cents">Prix Normal (en centimes)</label>
|
||||
<input class="form-input" type="number" id="price_normal_cents" name="price_normal_cents" min="1" value="<?= $val('price_normal_cents') ?>" required>
|
||||
<?php if ($err('price_normal_cents') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('price_normal_cents'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="price_maxi_cents">Prix Maxi (en centimes)</label>
|
||||
<input class="form-input" type="number" id="price_maxi_cents" name="price_maxi_cents" min="1" value="<?= $val('price_maxi_cents') ?>" required>
|
||||
<?php if ($err('price_maxi_cents') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('price_maxi_cents'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="display_order">Ordre d'affichage</label>
|
||||
<input class="form-input" type="number" id="display_order" name="display_order" min="0" value="<?= $val('display_order') ?>">
|
||||
<?php if ($err('display_order') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('display_order'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="form-group">
|
||||
<legend>Slots de composition</legend>
|
||||
<p><small>Au moins un slot, chacun avec au moins une option. Les choix proposes au client par slot.</small></p>
|
||||
<?php if ($err('slots') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('slots'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
<div id="slot-builder"
|
||||
data-products="<?= $attr($slimProducts) ?>"
|
||||
data-slot-types="<?= $attr($types) ?>"
|
||||
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
||||
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="slots_json" id="slots_json" value="">
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||
<a class="btn btn-secondary" href="/admin/menus">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/assets/js/menu-form.js"></script>
|
||||
76
src/app/Views/admin/menus/index.php
Normal file
76
src/app/Views/admin/menus/index.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste des menus (CRUD admin), injectee dans admin/layout.php. Texte echappe.
|
||||
* Le toggle de disponibilite est un POST CSRF (pas de JS).
|
||||
*
|
||||
* @var array<int, array<string, mixed>> $menus
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
/** @var array<int, array<string, mixed>> $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';
|
||||
?>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Menus</h1>
|
||||
<p class="page-subtitle">Gestion des menus composes (burger + slots)</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a class="btn btn-primary" href="/admin/menus/new">Nouveau menu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Categorie</th>
|
||||
<th>Burger de base</th>
|
||||
<th>Prix (Normal – Maxi)</th>
|
||||
<th>Statut</th>
|
||||
<th style="width:240px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($rows === []): ?>
|
||||
<tr><td colspan="6" class="muted">Aucun menu.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<?php
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$available = (int) ($row['is_available'] ?? 0) === 1;
|
||||
?>
|
||||
<tr>
|
||||
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
|
||||
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
|
||||
<td class="muted"><?= $esc($row['burger_name'] ?? '') ?></td>
|
||||
<td><?= $esc($euros((int) ($row['price_normal_cents'] ?? 0))) ?> – <?= $esc($euros((int) ($row['price_maxi_cents'] ?? 0))) ?></td>
|
||||
<td>
|
||||
<?php if ($available): ?>
|
||||
<span class="pill pill-success">Disponible</span>
|
||||
<?php else: ?>
|
||||
<span class="pill pill-neutral">Indisponible</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-secondary" href="/admin/menus/<?= $id ?>/edit">Modifier</a>
|
||||
<form method="post" action="/admin/menus/<?= $id ?>/toggle" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
<button class="btn btn-secondary" type="submit"><?= $available ? 'Desactiver' : 'Activer' ?></button>
|
||||
</form>
|
||||
<a class="btn btn-secondary" href="/admin/menus/<?= $id ?>/delete">Supprimer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
160
src/public/admin/assets/js/menu-form.js
Normal file
160
src/public/admin/assets/js/menu-form.js
Normal file
|
|
@ -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));
|
||||
}
|
||||
})();
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
137
tests/Integration/MenuRepositoryDbTest.php
Normal file
137
tests/Integration/MenuRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use App\Catalogue\MenuRepository;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* CRUD reel de MenuRepository contre une vraie MariaDB (schema migre + seede),
|
||||
* y compris la composition de slots et le delete-and-reinsert a l'update.
|
||||
* Auto-skip si WAKDO_DB_TESTS != 1. Menu jetable (nom it-menu-*), nettoyage en
|
||||
* tearDown (CASCADE retire slots + options).
|
||||
*/
|
||||
final class MenuRepositoryDbTest extends TestCase
|
||||
{
|
||||
private Database $db;
|
||||
private string $name = '';
|
||||
private int $categoryId = 0;
|
||||
/** @var list<int> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,30 @@ final class FakeDatabase implements DatabaseInterface
|
|||
*/
|
||||
public ?array $productRow = null;
|
||||
|
||||
/**
|
||||
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $menuRow = null;
|
||||
|
||||
/**
|
||||
* Lignes renvoyees par MenuRepository::all().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $menusRows = [];
|
||||
|
||||
/**
|
||||
* Lignes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions().
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
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 [];
|
||||
|
|
|
|||
329
tests/Unit/Admin/MenuControllerTest.php
Normal file
329
tests/Unit/Admin/MenuControllerTest.php
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Admin;
|
||||
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Controllers\MenuController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : seam db() (FakeDatabase) + sessionManager() (session test).
|
||||
*/
|
||||
final class TestMenuController extends MenuController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->testSession;
|
||||
}
|
||||
|
||||
protected function db(): DatabaseInterface
|
||||
{
|
||||
return $this->fakeDb;
|
||||
}
|
||||
}
|
||||
|
||||
final class MenuControllerTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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<string, string> $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<string, string> $overrides
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string|int, mixed>}|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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue