From fce6dae428edbceacfaa39d0da046d3e1a73cf8e Mon Sep 17 00:00:00 2001 From: Imugiii Date: Thu, 25 Jun 2026 12:27:48 +0000 Subject: [PATCH] feat(stock): reglage rapide capacite + seuils depuis la page Stock (modale + endpoint dedie) --- src/app/Catalogue/IngredientRepository.php | 17 +++ src/app/Controllers/IngredientController.php | 121 +++++++++++++--- src/app/Views/admin/ingredients/index.php | 88 +++++++++++- src/app/Views/admin/layout.php | 1 + src/public/admin/assets/css/admin.css | 33 +++++ .../admin/assets/js/stock-thresholds.js | 130 ++++++++++++++++++ src/public/admin/index.php | 5 + tests/Unit/Admin/IngredientControllerTest.php | 129 +++++++++++++++++ tests/js/stock-thresholds.test.js | 116 ++++++++++++++++ 9 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 src/public/admin/assets/js/stock-thresholds.js create mode 100644 tests/js/stock-thresholds.test.js diff --git a/src/app/Catalogue/IngredientRepository.php b/src/app/Catalogue/IngredientRepository.php index ad7a814..f00db0d 100644 --- a/src/app/Catalogue/IngredientRepository.php +++ b/src/app/Catalogue/IngredientRepository.php @@ -135,6 +135,23 @@ final class IngredientRepository ); } + /** + * Reglage rapide des seuils depuis le tableau de bord stock (F13). Cible UNIQUEMENT + * les trois colonnes de calibrage (capacite = reference 100 %, seuils alerte/critique + * en %), distinctes de update() qui exige aussi name/unit/pack. stock_quantity n'est + * jamais touche : le niveau ne bouge que via restock/inventoryCount (ledger). Les + * bornes (capacite >= 1, % 0-100, critique < alerte strict) sont validees par + * l'appelant (controleur, RG-T18), pas ici. + */ + public function updateThresholds(int $id, int $capacity, int $low, int $critical): void + { + $this->db->execute( + 'UPDATE ingredient SET stock_capacity = :cap, low_stock_pct = :low, ' + . 'critical_stock_pct = :crit WHERE id = :id', + ['cap' => $capacity, 'low' => $low, 'crit' => $critical, 'id' => $id], + ); + } + /** * Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement) * des qu'une recette ou un mouvement reference l'ingredient ; le controleur diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php index d4f581d..20579a0 100644 --- a/src/app/Controllers/IngredientController.php +++ b/src/app/Controllers/IngredientController.php @@ -47,6 +47,17 @@ class IngredientController extends AdminController return $guard; } + return $this->renderIndex($guard); + } + + /** + * Rend le tableau de bord stock. Factorise pour servir la lecture (index, 200) et le + * re-rendu en erreur du reglage rapide de seuils (updateThresholds, 422). $error est + * affiche en bandeau si present. Les drapeaux de permission pilotent l'affichage des + * actions (la garde reelle reste par-route). + */ + private function renderIndex(GuardResult $guard, ?string $error = null, int $status = 200): Response + { $ingredients = $this->ingredientRepository()->all(); // Compteurs par bande pour le resume du tableau de bord (3 pastilles). @@ -59,14 +70,15 @@ class IngredientController extends AdminController } return $this->adminView('admin/ingredients/index', [ - 'title' => 'Stock - Wakdo Admin', - 'activeNav' => 'stock', - 'ingredients' => $ingredients, - 'bandCounts' => $counts, - 'canManage' => $this->may($guard, 'ingredient.manage'), - 'canRestock' => $this->may($guard, 'stock.manage'), - 'canCount' => $this->may($guard, 'stock.count'), - ], $guard); + 'title' => 'Stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredients' => $ingredients, + 'bandCounts' => $counts, + 'canManage' => $this->may($guard, 'ingredient.manage'), + 'canRestock' => $this->may($guard, 'stock.manage'), + 'canCount' => $this->may($guard, 'stock.count'), + 'thresholdError' => $error, + ], $guard, $status); } /** @@ -303,6 +315,49 @@ class IngredientController extends AdminController return $this->redirect('/admin/ingredients'); } + /** + * Reglage rapide des seuils depuis le tableau de bord stock (F13). Endpoint LEGER : + * ne reutilise PAS update() (qui exige name/unit/pack), il ne valide et n'ecrit que + * capacite + seuils alerte/critique. Permission stock.manage (calibrage du stock, pas + * du catalogue), CSRF, SANS PIN (config, pas un comptage d'inventaire RG-T13). Succes + * -> redirect liste + flash ; erreur de validation -> 422 re-rendu de la liste avec un + * bandeau d'erreur (convention restock/inventory : 422 + message, pas de redirect muet). + * + * @param array $params + */ + public function updateThresholds(array $params): Response + { + $guard = $this->guard('stock.manage'); + 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); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validateThresholds($form); + if ($errors !== []) { + // Premier message d'erreur en bandeau : la modale est rouverte cote client si + // besoin ; l'equipier voit la raison du rejet sans jargon de champ. + $messages = array_values($errors); + + return $this->renderIndex($guard, $messages[0], 422); + } + + $this->ingredientRepository()->updateThresholds($id, $data['stock_capacity'], $data['low_stock_pct'], $data['critical_stock_pct']); + $this->setFlash('Seuils mis a jour.'); + + return $this->redirect('/admin/ingredients'); + } + /** * @param array $params */ @@ -562,12 +617,6 @@ class IngredientController extends AdminController $errors['unit'] = 'L unite est requise (40 caracteres max).'; } - $capRaw = trim($form['stock_capacity'] ?? ''); - $capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647; - if (!$capValid) { - $errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.'; - } - $packRaw = trim($form['pack_size'] ?? ''); $packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535; if (!$packValid) { @@ -579,6 +628,45 @@ class IngredientController extends AdminController $errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).'; } + // Capacite + seuils : meme regle (capacite >= 1, % 0-100, critique < alerte strict) + // que le reglage rapide F13 -> source unique validateThresholds(), pas de copie + // divergente. Les messages restent indexes par champ pour le formulaire complet. + [$thresholds, $thresholdErrors] = $this->validateThresholds($form); + $errors = array_merge($errors, $thresholdErrors); + + $data = [ + 'name' => $name, + 'unit' => $unit, + 'stock_capacity' => $thresholds['stock_capacity'], + 'pack_size' => $packValid ? (int) $packRaw : 0, + 'pack_label' => $label !== '' ? $label : null, + 'low_stock_pct' => $thresholds['low_stock_pct'], + 'critical_stock_pct' => $thresholds['critical_stock_pct'], + ]; + + return [$data, $errors]; + } + + /** + * Validation des trois reglages de calibrage du stock, partagee par le formulaire + * complet (validate) et l'endpoint leger F13 (updateThresholds) pour qu'une seule + * regle existe : capacite (reference 100 %) >= 1 ; seuils alerte/critique entiers + * 0-100 ; critique STRICTEMENT inferieur a alerte (RG-CREATE-ING, garanti aussi par + * un CHECK de table). Renvoie [valeurs normalisees, erreurs indexees par champ]. + * + * @param array $form + * @return array{0: array{stock_capacity: int, low_stock_pct: int, critical_stock_pct: int}, 1: array} + */ + private function validateThresholds(array $form): array + { + $errors = []; + + $capRaw = trim($form['stock_capacity'] ?? ''); + $capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647; + if (!$capValid) { + $errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.'; + } + $lowRaw = trim($form['low_stock_pct'] ?? ''); $lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100; if (!$lowValid) { @@ -591,17 +679,12 @@ class IngredientController extends AdminController $errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.'; } - // RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict). if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) { $errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; } $data = [ - 'name' => $name, - 'unit' => $unit, 'stock_capacity' => $capValid ? (int) $capRaw : 0, - 'pack_size' => $packValid ? (int) $packRaw : 0, - 'pack_label' => $label !== '' ? $label : null, 'low_stock_pct' => $lowValid ? (int) $lowRaw : 0, 'critical_stock_pct' => $critValid ? (int) $critRaw : 0, ]; diff --git a/src/app/Views/admin/ingredients/index.php b/src/app/Views/admin/ingredients/index.php index 2b1e896..35a797e 100644 --- a/src/app/Views/admin/ingredients/index.php +++ b/src/app/Views/admin/ingredients/index.php @@ -12,10 +12,11 @@ declare(strict_types=1); * * @var array> $ingredients * @var array $bandCounts - * @var bool $canManage - * @var bool $canRestock - * @var bool $canCount - * @var string $csrfToken + * @var bool $canManage + * @var bool $canRestock + * @var bool $canCount + * @var string|null $thresholdError + * @var string $csrfToken */ /** @var array> $rows */ @@ -31,6 +32,29 @@ $count = (bool) ($canCount ?? false); $nCritical = (int) ($counts['critical'] ?? 0); $nLow = (int) ($counts['low'] ?? 0); $nNormal = (int) ($counts['normal'] ?? 0); +$thresholdErr = isset($thresholdError) && is_string($thresholdError) ? $thresholdError : null; + +/** + * Bouton "Regler les seuils" (F13). Ouvre la modale pre-remplie via des data-attributes + * (l'id pour l'action POST, le nom pour le titre, les trois valeurs courantes) ; le JS + * stock-thresholds.js intercepte le clic. Affiche seulement si le role peut calibrer le + * stock ($restock = stock.manage). Valeurs echappees (attributs HTML). + * + * @param array $row + */ +$renderThresholdButton = static function (array $row) use ($esc, $restock): string { + if (!$restock) { + return ''; + } + $id = (int) ($row['id'] ?? 0); + + return ''; +}; // Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste // (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas. @@ -92,6 +116,10 @@ $renderBar = static function (array $row) use ($esc, $barClass): string { + + + +

Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une recette qui passe sous son seuil critique rend les produits qui l utilisent @@ -137,9 +165,12 @@ $renderBar = static function (array $row) use ($esc, $barClass): string { - - Reapprovisionner - +

+ + Reapprovisionner + + +
@@ -172,6 +203,7 @@ $renderBar = static function (array $row) use ($esc, $barClass): string { Inventaire + Mouvements @@ -189,3 +221,45 @@ $renderBar = static function (array $row) use ($esc, $barClass): string { + + + + + diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index d35bee5..01c97f7 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -169,5 +169,6 @@ $navClass = static function (string $code, string $current): string { + diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 55cc3e0..c894291 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -2103,3 +2103,36 @@ tbody td.mono { justify-content: flex-start; } } + +/* Bandeau de confirmation (flash succes, pose par setFlash apres redirection) et sa + variante erreur (re-rendu 422 du reglage rapide de seuils, F13). */ +.flash { + color: var(--color-success-text); + background: var(--color-success-bg); + border-radius: var(--radius-md); + padding: 10px 14px; + margin-bottom: 16px; + font-size: 14px; +} +.flash-error { + color: var(--color-danger-text); + background: var(--color-danger-bg); +} + +/* Aide sous un champ de formulaire (texte explicatif, pas une erreur). */ +.form-hint { + color: var(--color-text-muted); + font-size: 12px; + margin-top: 4px; +} + +/* Zone d'actions d'une carte a reapprovisionner : reappro + reglage des seuils empiles. */ +.stock-card__actions { + display: flex; + flex-direction: column; + gap: 8px; +} +.stock-card__actions .btn-sm { + width: 100%; + justify-content: center; +} diff --git a/src/public/admin/assets/js/stock-thresholds.js b/src/public/admin/assets/js/stock-thresholds.js new file mode 100644 index 0000000..a6ba160 --- /dev/null +++ b/src/public/admin/assets/js/stock-thresholds.js @@ -0,0 +1,130 @@ +/** + * stock-thresholds.js — Reglage rapide des seuils de stock depuis le tableau de bord (F13). + * + * Chaque carte/ligne ingredient porte un bouton "Regler les seuils" (data-threshold-open) + * decore de ses valeurs courantes (data-id, data-name, data-capacity, data-low, + * data-critical). Au clic, on pre-remplit l'unique modale rendue serveur (un VRAI form POST + * avec CSRF, pas de fetch), on pointe son action sur /admin/ingredients/{id}/thresholds, et + * on l'ouvre. La validation finale reste cote serveur (validateThresholds) ; on ajoute ici + * un garde-fou client leger (capacite >= 1, % 0-100, critique < alerte strict) pour eviter + * un aller-retour evident. Sans JS, la modale reste cachee et le reste de la page marche. + * + * CSP 'self' : script externe, aucun handler inline. Style CommonJS testable + browser-safe. + */ +(function () { + 'use strict'; + + function init(doc) { + var overlay = doc.querySelector('[data-threshold-modal]'); + if (!overlay) { + return; // role sans stock.manage : la modale n'est pas rendue. + } + + var form = overlay.querySelector('[data-threshold-form]'); + var inCapacity = doc.getElementById('th-capacity'); + var inLow = doc.getElementById('th-low'); + var inCritical = doc.getElementById('th-critical'); + var nameLabel = overlay.querySelector('[data-threshold-name]'); + var errorBox = overlay.querySelector('[data-threshold-error]'); + if (!form || !inCapacity || !inLow || !inCritical) { + return; + } + + var openers = doc.querySelectorAll('[data-threshold-open]'); + for (var i = 0; i < openers.length; i++) { + openers[i].addEventListener('click', function (e) { + openModal(e.currentTarget); + }); + } + + overlay.querySelector('[data-threshold-cancel]').addEventListener('click', closeModal); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) { + closeModal(); + } + }); + doc.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && overlay.classList.contains('open')) { + closeModal(); + } + }); + + // Garde-fou client : on ne bloque que les cas evidents (le serveur reste l'autorite). + form.addEventListener('submit', function (e) { + var error = validate(inCapacity.value, inLow.value, inCritical.value); + if (error !== null) { + e.preventDefault(); + if (errorBox) { + errorBox.textContent = error; + errorBox.hidden = false; + } + } + }); + + function openModal(button) { + var id = button.getAttribute('data-id') || ''; + form.setAttribute('action', '/admin/ingredients/' + id + '/thresholds'); + if (nameLabel) { + var name = button.getAttribute('data-name') || ''; + nameLabel.textContent = name === '' ? '' : 'Ingredient : ' + name; + } + inCapacity.value = button.getAttribute('data-capacity') || ''; + inLow.value = button.getAttribute('data-low') || ''; + inCritical.value = button.getAttribute('data-critical') || ''; + if (errorBox) { + errorBox.hidden = true; + } + overlay.classList.add('open'); + inCapacity.focus(); + } + + function closeModal() { + overlay.classList.remove('open'); + } + } + + /** + * Validation cliente legere, miroir de validateThresholds() cote serveur : + * capacite entiere >= 1 ; seuils entiers 0-100 ; critique STRICTEMENT < alerte. + * Renvoie un message d'erreur (string) ou null si tout est coherent. + */ + function validate(capacityRaw, lowRaw, criticalRaw) { + var capacity = toInt(capacityRaw); + var low = toInt(lowRaw); + var critical = toInt(criticalRaw); + + if (capacity === null || capacity < 1) { + return 'La capacite (reference 100%) doit etre un entier superieur ou egal a 1.'; + } + if (low === null || low < 0 || low > 100) { + return 'Le seuil d alerte doit etre un entier entre 0 et 100.'; + } + if (critical === null || critical < 0 || critical > 100) { + return 'Le seuil critique doit etre un entier entre 0 et 100.'; + } + if (critical >= low) { + return 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; + } + + return null; + } + + /** Entier strict (suite de chiffres) ou null : refuse "", " 5", "5.0", "abc". */ + function toInt(raw) { + var value = String(raw === undefined || raw === null ? '' : raw).trim(); + if (!/^[0-9]+$/.test(value)) { + return null; + } + + return parseInt(value, 10); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, validate: validate }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } +})(); diff --git a/src/public/admin/index.php b/src/public/admin/index.php index b345bc7..fc77eb8 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -221,6 +221,11 @@ try { $router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']); $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); $router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); + // Reglage rapide des seuils (F13) : capacite/alerte/critique edites depuis la page + // Stock via une modale, sans passer par le formulaire complet. stock.manage (calibrage + // du stock), CSRF, SANS PIN (config, pas un comptage d'inventaire). {id} = un seul + // segment ; /thresholds ne chevauche ni /restock ni /inventory. + $router->add('POST', '/admin/ingredients/{id}/thresholds', [IngredientController::class, 'updateThresholds']); $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php index 6601e3d..86ddedc 100644 --- a/tests/Unit/Admin/IngredientControllerTest.php +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -256,6 +256,41 @@ final class IngredientControllerTest extends TestCase self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*au-dessus du seuil/', $body); } + public function testIndexExposesThresholdButtonWithCurrentValuesForStockManager(): void + { + // F13 : la page Stock porte un acces rapide "Regler les seuils" decore des + // valeurs courantes (data-attributes) qui pre-remplissent la modale, plus la + // modale rendue serveur (VRAI form POST). Visible pour stock.manage. + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient(['stock_capacity' => 100, 'low_stock_pct' => 12, 'critical_stock_pct' => 4])]; + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + self::assertStringContainsString('Regler les seuils', $body); + self::assertStringContainsString('data-threshold-open', $body); + self::assertStringContainsString('data-capacity="100"', $body); + self::assertStringContainsString('data-low="12"', $body); + self::assertStringContainsString('data-critical="4"', $body); + // Modale rendue serveur avec un VRAI form POST (pas de fetch) + champs cibles. + self::assertStringContainsString('data-threshold-modal', $body); + self::assertStringContainsString('name="stock_capacity"', $body); + self::assertStringContainsString('name="critical_stock_pct"', $body); + } + + public function testIndexHidesThresholdAccessWithoutStockManage(): void + { + // Sans stock.manage (lecture seule), ni le bouton ni la modale ne sont rendus : + // l'acces rapide suit la permission de calibrage. + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read']; + $db->ingredientsRows = [$this->ingredient()]; + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + self::assertStringNotContainsString('data-threshold-open', $body); + self::assertStringNotContainsString('data-threshold-modal', $body); + } + public function testIndexForbiddenWithoutStockRead(): void { $db = $this->permittedDb(); @@ -427,6 +462,100 @@ final class IngredientControllerTest extends TestCase self::assertStringContainsString('reference', $response->body()); } + // --- THRESHOLDS (F13, stock.manage, SANS PIN) : reglage rapide capacite + seuils --- + + public function testUpdateThresholdsWritesOnlyTheThreeColumns(): void + { + $db = $this->permittedDb(); + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '200', 'low_stock_pct' => '15', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/ingredients', $response->header('Location')); + $params = $this->writeParams($db, 'UPDATE ingredient SET stock_capacity'); + self::assertNotNull($params); + self::assertSame(200, $params['cap']); + self::assertSame(15, $params['low']); + self::assertSame(5, $params['crit']); + // Endpoint leger : ne touche ni le stock ni le catalogue (name/unit/pack/is_active). + $sql = $this->writeSql($db, 'UPDATE ingredient SET stock_capacity'); + self::assertStringNotContainsString('stock_quantity', $sql); + self::assertStringNotContainsString('is_active', $sql); + self::assertStringNotContainsString('name', $sql); + self::assertStringNotContainsString('pack_size', $sql); + } + + public function testUpdateThresholdsRejectsCapacityBelowOne(): void + { + $db = $this->permittedDb(); + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '0', 'low_stock_pct' => '15', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + self::assertStringContainsString('capacite', $response->body()); + } + + public function testUpdateThresholdsRejectsPercentageAboveHundred(): void + { + $db = $this->permittedDb(); + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '120', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + } + + public function testUpdateThresholdsRejectsCriticalNotStrictlyBelowLow(): void + { + $db = $this->permittedDb(); + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '10']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + self::assertStringContainsString('strictement inferieur', $response->body()); + } + + public function testUpdateThresholdsForbiddenWithoutStockManage(): void + { + $db = $this->permittedDb(); + $db->grantedCodes = ['stock.read']; // lecture sans stock.manage + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + } + + public function testUpdateThresholdsRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $form = ['_csrf' => 'bad', 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/5/thresholds'), $db)->updateThresholds(['id' => '5']); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + } + + public function testUpdateThresholdsNotFound(): void + { + $db = $this->permittedDb(); + $db->ingredientRow = null; + $form = ['_csrf' => $this->csrf, 'stock_capacity' => '100', 'low_stock_pct' => '10', 'critical_stock_pct' => '5']; + + $response = $this->controller($this->post($form, '/admin/ingredients/9/thresholds'), $db)->updateThresholds(['id' => '9']); + + self::assertSame(404, $response->status()); + self::assertFalse($db->wrote('UPDATE ingredient SET stock_capacity')); + } + // --- RESTOCK (9.1, stock.manage, SANS PIN) --- public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void diff --git a/tests/js/stock-thresholds.test.js b/tests/js/stock-thresholds.test.js new file mode 100644 index 0000000..4f53ab3 --- /dev/null +++ b/tests/js/stock-thresholds.test.js @@ -0,0 +1,116 @@ +/* + * Tests du reglage rapide des seuils de stock (F13) du back-office (node:test + jsdom). + * + * Couvre la logique testable du module : pre-remplissage de la modale depuis les + * data-attributes du bouton + pointage de l'action POST sur l'id, ouverture/fermeture, + * et le garde-fou client validate() (capacite >= 1, % 0-100, critique < alerte strict). + * La modale est rendue serveur (VRAI form POST) ; le module n'ajoute pas de fetch. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// stock-thresholds.js est du CommonJS (admin = racine CommonJS) ; import par defaut. +import stockThresholds from '../../src/public/admin/assets/js/stock-thresholds.js'; + +function setup() { + const dom = new JSDOM( + '' + + '' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '', + ); + return dom; +} + +function fire(dom, el, type) { + el.dispatchEvent(new dom.window.Event(type, { cancelable: true, bubbles: true })); +} + +test('validate accepte une configuration coherente et rejette les cas evidents', () => { + // Coherent. + assert.equal(stockThresholds.validate('200', '15', '5'), null); + // Capacite < 1. + assert.match(stockThresholds.validate('0', '15', '5'), /capacite/i); + // Pourcentage hors 0-100. + assert.match(stockThresholds.validate('100', '120', '5'), /alerte/i); + assert.match(stockThresholds.validate('100', '15', '200'), /critique/i); + // Critique non strictement inferieur a l'alerte. + assert.match(stockThresholds.validate('100', '10', '10'), /strictement inferieur/i); + // Saisies non entieres refusees (miroir de ctype_digit cote serveur). + assert.notEqual(stockThresholds.validate('', '15', '5'), null); + assert.notEqual(stockThresholds.validate('10.5', '15', '5'), null); +}); + +test('le clic sur un bouton pre-remplit la modale et pointe l action POST sur l id', () => { + const dom = setup(); + const doc = dom.window.document; + stockThresholds.init(doc); + + fire(dom, doc.querySelector('[data-threshold-open]'), 'click'); + + const overlay = doc.querySelector('[data-threshold-modal]'); + assert.equal(overlay.classList.contains('open'), true); + assert.equal(doc.querySelector('[data-threshold-form]').getAttribute('action'), '/admin/ingredients/7/thresholds'); + assert.equal(doc.getElementById('th-capacity').value, '200'); + assert.equal(doc.getElementById('th-low').value, '15'); + assert.equal(doc.getElementById('th-critical').value, '5'); +}); + +test('soumettre une configuration incoherente bloque le POST et affiche l erreur', () => { + const dom = setup(); + const doc = dom.window.document; + stockThresholds.init(doc); + + fire(dom, doc.querySelector('[data-threshold-open]'), 'click'); + // Critique >= alerte : le garde-fou client doit annuler la soumission. + doc.getElementById('th-critical').value = '15'; + const form = doc.querySelector('[data-threshold-form]'); + const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true }); + form.dispatchEvent(evt); + + assert.equal(evt.defaultPrevented, true); + assert.equal(doc.querySelector('[data-threshold-error]').hidden, false); +}); + +test('soumettre une configuration coherente laisse le form POST partir', () => { + const dom = setup(); + const doc = dom.window.document; + stockThresholds.init(doc); + + fire(dom, doc.querySelector('[data-threshold-open]'), 'click'); + const form = doc.querySelector('[data-threshold-form]'); + const evt = new dom.window.Event('submit', { cancelable: true, bubbles: true }); + form.dispatchEvent(evt); + + // Valeurs pre-remplies coherentes (200/15/5) : pas de blocage client (POST reel). + assert.equal(evt.defaultPrevented, false); +}); + +test('Annuler ferme la modale', () => { + const dom = setup(); + const doc = dom.window.document; + stockThresholds.init(doc); + + fire(dom, doc.querySelector('[data-threshold-open]'), 'click'); + assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), true); + + fire(dom, doc.querySelector('[data-threshold-cancel]'), 'click'); + assert.equal(doc.querySelector('[data-threshold-modal]').classList.contains('open'), false); +}); + +test('init sans modale (role sans stock.manage) ne plante pas', () => { + const dom = new JSDOM(''); + assert.doesNotThrow(() => stockThresholds.init(dom.window.document)); +}); -- 2.45.3