Refonte de la page d'accueil Ingredients/Stock, jugee trop chargee et opaque. Desormais : un bandeau explique le lien stock -> disponibilite borne (un ingredient requis sous le seuil critique rend les produits qui l'utilisent indisponibles a la commande, RG-T21) ; un resume compte les ingredients critiques / en alerte / au-dessus du seuil ; une section "A reapprovisionner" met en avant les ingredients bas (critiques d'abord) avec barre de niveau + bouton Reapprovisionner direct ; la liste complete passe au second plan et le CRUD (creer / modifier / supprimer) est relegue. Les sous-pages (reappro, inventaire, mouvements, creation) restent inchangees. index() expose les compteurs par etat (testables). Tests : IngredientController +4 cas (bandeau, promotion d'un critique en section reappro, etat vide positif, compteurs par etat). PHP unit 409, JS 119, PHPStan L6.
560 lines
22 KiB
PHP
560 lines
22 KiB
PHP
<?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\Catalogue\NutritionGateway;
|
|
use App\Controllers\IngredientController;
|
|
use App\Core\Config;
|
|
use App\Core\Database;
|
|
use App\Core\DatabaseInterface;
|
|
use App\Core\Request;
|
|
use App\Tests\Support\FakeDatabase;
|
|
use App\Tests\Support\FakeNutritionGateway;
|
|
|
|
/**
|
|
* Sous-classe de test : le seam db() injecte le double, sessionManager() la session.
|
|
*/
|
|
final class TestIngredientController extends IngredientController
|
|
{
|
|
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;
|
|
}
|
|
|
|
public ?FakeNutritionGateway $fakeGateway = null;
|
|
|
|
protected function nutritionGateway(): NutritionGateway
|
|
{
|
|
return $this->fakeGateway ?? new FakeNutritionGateway();
|
|
}
|
|
}
|
|
|
|
final class IngredientControllerTest 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' => 'Sam', 'last_name' => 'K', 'role_label' => 'Manager'];
|
|
$db->canResult = true;
|
|
$db->permissionCodes = ['stock.read', 'ingredient.manage', 'stock.manage', 'stock.count'];
|
|
$db->ingredientRow = $this->ingredient();
|
|
|
|
return $db;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function ingredient(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'id' => 5, 'name' => 'Cheddar', 'unit' => 'tranche',
|
|
'stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10,
|
|
'pack_label' => 'Sachet 10', 'low_stock_pct' => 10, 'critical_stock_pct' => 5,
|
|
'is_active' => 1,
|
|
], $overrides);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $overrides
|
|
* @return array<string, string>
|
|
*/
|
|
private function validForm(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'_csrf' => $this->csrf,
|
|
'name' => 'Cheddar',
|
|
'unit' => 'tranche',
|
|
'stock_capacity' => '100',
|
|
'pack_size' => '10',
|
|
'pack_label' => 'Sachet 10',
|
|
'low_stock_pct' => '10',
|
|
'critical_stock_pct' => '5',
|
|
], $overrides);
|
|
}
|
|
|
|
private function actingPin(FakeDatabase $db): void
|
|
{
|
|
$db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')];
|
|
}
|
|
|
|
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): TestIngredientController
|
|
{
|
|
return new TestIngredientController($request, new Config(), new Database(new Config()), $this->session, $db);
|
|
}
|
|
|
|
/**
|
|
* @return array<string|int, mixed>|null
|
|
*/
|
|
private function writeParams(FakeDatabase $db, string $needle): ?array
|
|
{
|
|
foreach ($db->writes as $write) {
|
|
if (str_contains($write['sql'], $needle)) {
|
|
return $write['params'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function writeSql(FakeDatabase $db, string $needle): string
|
|
{
|
|
foreach ($db->writes as $write) {
|
|
if (str_contains($write['sql'], $needle)) {
|
|
return $write['sql'];
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// --- Lecture (READ_STOCK 9.3) ---
|
|
|
|
public function testIndexListsStockForStockReader(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->ingredientsRows = [$this->ingredient(['stock_quantity' => 8])]; // 8% -> bande alerte
|
|
|
|
$response = $this->controller($this->get('/admin/ingredients'), $db)->index();
|
|
|
|
self::assertSame(200, $response->status());
|
|
self::assertStringContainsString('Cheddar', $response->body());
|
|
self::assertStringContainsString('Alerte', $response->body());
|
|
}
|
|
|
|
public function testIndexShowsBusinessExplainerBanner(): void
|
|
{
|
|
// Le bandeau explique le lien metier stock -> disponibilite borne (RG-T21),
|
|
// l'info qui manquait dans l'ancien tableau brut.
|
|
$db = $this->permittedDb();
|
|
$db->ingredientsRows = [$this->ingredient()];
|
|
|
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
|
|
|
self::assertStringContainsString('stock-explainer', $body);
|
|
self::assertStringContainsString('borne', $body);
|
|
}
|
|
|
|
public function testIndexPromotesLowAndCriticalIntoRestockSection(): void
|
|
{
|
|
// Un ingredient critique (3% < seuil 5) doit apparaitre dans la section
|
|
// "A reapprovisionner" mise en avant, pas seulement dans la liste calme.
|
|
$db = $this->permittedDb();
|
|
$db->ingredientsRows = [$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3])];
|
|
|
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
|
|
|
self::assertStringContainsString('A reapprovisionner', $body);
|
|
self::assertStringContainsString('stock-section--restock', $body);
|
|
self::assertStringContainsString('Buns', $body);
|
|
self::assertStringContainsString('Critique', $body);
|
|
}
|
|
|
|
public function testIndexShowsPositiveEmptyStateWhenNothingLow(): void
|
|
{
|
|
// Tous au-dessus des seuils -> etat vide positif dans la section restock.
|
|
$db = $this->permittedDb();
|
|
$db->ingredientsRows = [$this->ingredient(['stock_quantity' => 100])]; // 100% -> normal
|
|
|
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
|
|
|
self::assertStringContainsString('au-dessus de leurs seuils', $body);
|
|
}
|
|
|
|
public function testIndexCountsIngredientsPerBand(): void
|
|
{
|
|
// Resume en haut : 1 critique (3%), 1 alerte (8%), 1 au-dessus (100%).
|
|
$db = $this->permittedDb();
|
|
$db->ingredientsRows = [
|
|
$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3]),
|
|
$this->ingredient(['name' => 'Cheddar', 'stock_quantity' => 8]),
|
|
$this->ingredient(['name' => 'Salade', 'stock_quantity' => 100]),
|
|
];
|
|
|
|
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
|
|
|
|
// Chaque compteur est verrouille a SON libelle (sinon une regex generique
|
|
// passerait meme avec les trois compteurs inverses).
|
|
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">critiques/', $body);
|
|
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">en alerte/', $body);
|
|
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
|
|
}
|
|
|
|
public function testIndexForbiddenWithoutStockRead(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->canResult = false;
|
|
|
|
self::assertSame(403, $this->controller($this->get('/admin/ingredients'), $db)->index()->status());
|
|
}
|
|
|
|
// --- CRUD ingredient (8.8, ingredient.manage, SANS PIN) ---
|
|
|
|
public function testStoreCreatesWithZeroStockAndActiveServerSet(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(302, $response->status());
|
|
$params = $this->writeParams($db, 'INSERT INTO ingredient');
|
|
self::assertNotNull($params);
|
|
self::assertSame(0, $params['qty']); // stock_quantity initial = 0 (RG-CREATE-ING)
|
|
self::assertSame(1, $params['active']); // is_active pose cote serveur (RG-T16)
|
|
}
|
|
|
|
public function testStoreRejectsInvalidInput(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post($this->validForm(['name' => '', 'stock_capacity' => '0']), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertFalse($db->wrote('INSERT INTO ingredient'));
|
|
}
|
|
|
|
public function testStoreRejectsCriticalNotStrictlyBelowLow(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post($this->validForm(['low_stock_pct' => '5', 'critical_stock_pct' => '5']), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertStringContainsString('strictement inferieur', $response->body());
|
|
}
|
|
|
|
public function testStoreRejectsDuplicateName(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->ingredientNameTaken = true;
|
|
|
|
$response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertFalse($db->wrote('INSERT INTO ingredient'));
|
|
}
|
|
|
|
public function testStoreTranslatesUniqueRaceTo409(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->failOnExecute = new PDOException('duplicate', 23000);
|
|
|
|
$response = $this->controller($this->post($this->validForm(), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(409, $response->status());
|
|
}
|
|
|
|
public function testStoreRejectsInvalidCsrf(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post($this->validForm(['_csrf' => 'bad']), '/admin/ingredients'), $db)->store();
|
|
|
|
self::assertSame(403, $response->status());
|
|
}
|
|
|
|
public function testUpdateDoesNotBindStockOrActive(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post($this->validForm(), '/admin/ingredients/5'), $db)->update(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
$sql = $this->writeSql($db, 'UPDATE ingredient');
|
|
self::assertNotSame('', $sql);
|
|
self::assertStringNotContainsString('stock_quantity', $sql); // RG-T16
|
|
self::assertStringNotContainsString('is_active', $sql); // RG-T16 (bascule via toggle)
|
|
}
|
|
|
|
public function testUpdateNotFound(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->ingredientRow = null;
|
|
|
|
self::assertSame(404, $this->controller($this->post($this->validForm(), '/admin/ingredients/9'), $db)->update(['id' => '9'])->status());
|
|
}
|
|
|
|
public function testToggleFlipsActive(): void
|
|
{
|
|
$db = $this->permittedDb(); // is_active = 1 -> doit basculer a 0
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/toggle'), $db)->toggle(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
$params = $this->writeParams($db, 'UPDATE ingredient SET is_active');
|
|
self::assertNotNull($params);
|
|
self::assertSame(0, $params['a']);
|
|
}
|
|
|
|
public function testEnrichStoresNutritionFromExternalApi(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db);
|
|
$c->fakeGateway = new FakeNutritionGateway();
|
|
$c->fakeGateway->result = ['energy_kcal_100g' => 402, 'source' => 'OpenFoodFacts'];
|
|
|
|
$response = $c->enrich(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
self::assertSame('/admin/ingredients/5/edit', $response->header('Location'));
|
|
// La source externe est interrogee avec le NOM de l'ingredient, et la donnee
|
|
// retournee est ecrite DANS LE MODELE (Cr 3.a.3).
|
|
self::assertSame('Cheddar', $c->fakeGateway->lookedUp);
|
|
$params = $this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g');
|
|
self::assertNotNull($params);
|
|
self::assertSame(402, $params['kcal']);
|
|
self::assertSame('OpenFoodFacts', $params['src']);
|
|
}
|
|
|
|
public function testEnrichWithoutResultDoesNotWrite(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$c = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/enrich'), $db);
|
|
$c->fakeGateway = new FakeNutritionGateway(); // result reste null
|
|
|
|
$response = $c->enrich(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
// Aucune ecriture nutritionnelle si la source externe ne renvoie rien.
|
|
self::assertNull($this->writeParams($db, 'UPDATE ingredient SET energy_kcal_100g'));
|
|
}
|
|
|
|
public function testEditShowsImportedNutrition(): void
|
|
{
|
|
// Regression : renderForm doit transmettre la nutrition pour que le panneau
|
|
// d'enrichissement reflete la valeur importee (et pas "aucune donnee").
|
|
$db = $this->permittedDb();
|
|
$db->ingredientRow = $this->ingredient([
|
|
'energy_kcal_100g' => 402,
|
|
'nutrition_source' => 'OpenFoodFacts',
|
|
'nutrition_fetched_at' => '2026-06-22 10:00:00',
|
|
]);
|
|
|
|
$body = $this->controller($this->get('/admin/ingredients/5/edit'), $db)->edit(['id' => '5'])->body();
|
|
|
|
self::assertStringContainsString('402 kcal', $body);
|
|
self::assertStringContainsString('OpenFoodFacts', $body);
|
|
}
|
|
|
|
public function testDestroyUnreferencedDeletesWithoutPin(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
// Aucun champ PIN dans le form : 8.8 n'est pas une action sensible.
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
self::assertTrue($db->wrote('DELETE FROM ingredient'));
|
|
}
|
|
|
|
public function testDestroyReferencedReturns409(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->failOnExecute = new PDOException('fk', 23000); // FK RESTRICT (recette / mouvement)
|
|
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/ingredients/5/delete'), $db)->destroy(['id' => '5']);
|
|
|
|
self::assertSame(409, $response->status());
|
|
self::assertStringContainsString('reference', $response->body());
|
|
}
|
|
|
|
// --- RESTOCK (9.1, stock.manage, SANS PIN) ---
|
|
|
|
public function testRestockAddsPacksAndRecordsMovementUnderSessionActor(): void
|
|
{
|
|
$db = $this->permittedDb(); // pack_size 10
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2', 'note' => 'Livraison A'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
self::assertSame(['begin', 'commit'], $db->transactionEvents);
|
|
self::assertTrue($db->wrote('SET stock_quantity = stock_quantity +'));
|
|
$movement = $this->writeParams($db, 'INSERT INTO stock_movement');
|
|
self::assertNotNull($movement);
|
|
self::assertSame('restock', $movement['type']);
|
|
self::assertSame(20, $movement['delta']); // 2 packs x pack_size 10
|
|
self::assertSame(1, $movement['user']); // acteur de SESSION (RG-4), pas un PIN
|
|
self::assertSame([], $db->auditActions()); // pas d'audit_log (RG-T14)
|
|
}
|
|
|
|
public function testRestockRejectedWhenInactive(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->ingredientRow = $this->ingredient(['is_active' => 0]); // PRE-2
|
|
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '2'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']);
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertFalse($db->wrote('stock_movement'));
|
|
}
|
|
|
|
public function testRestockRejectsPacksBelowOne(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$response = $this->controller($this->post(['_csrf' => $this->csrf, 'packs' => '0'], '/admin/ingredients/5/restock'), $db)->restock(['id' => '5']);
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertFalse($db->wrote('stock_movement'));
|
|
}
|
|
|
|
// --- INVENTORY_COUNT (9.2, stock.count + PIN) ---
|
|
|
|
public function testInventoryWithValidPinRecordsCorrectionUnderPinActorWithoutAudit(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$this->actingPin($db); // equipier id 9, PIN 4729
|
|
|
|
$response = $this->controller($this->post([
|
|
'_csrf' => $this->csrf, 'actual_quantity' => '30', 'note' => 'mensuel',
|
|
'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
|
], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']);
|
|
|
|
self::assertSame(302, $response->status());
|
|
$movement = $this->writeParams($db, 'INSERT INTO stock_movement');
|
|
self::assertNotNull($movement);
|
|
self::assertSame('inventory_correction', $movement['type']);
|
|
self::assertSame(-10, $movement['delta']); // 30 compte - 40 theorique
|
|
self::assertSame(9, $movement['user']); // acteur resolu par PIN (RG-4)
|
|
self::assertSame([], $db->auditActions()); // RG-T14 : pas de double-journal
|
|
}
|
|
|
|
public function testInventoryWithBadPinLogsFailedAndChangesNoStock(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->actingUserRow = null; // email/PIN non resolu
|
|
|
|
$response = $this->controller($this->post([
|
|
'_csrf' => $this->csrf, 'actual_quantity' => '30',
|
|
'pin_email' => 'ghost@wakdo.local', 'pin' => '0000',
|
|
], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']);
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22)
|
|
self::assertFalse($db->wrote('stock_movement')); // aucun effet sur le stock
|
|
}
|
|
|
|
public function testInventoryLockedActorReturns422WithoutEffect(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$this->actingPin($db);
|
|
$db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', time() + 300); // verrou actif
|
|
|
|
$response = $this->controller($this->post([
|
|
'_csrf' => $this->csrf, 'actual_quantity' => '30',
|
|
'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
|
], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']);
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou (RG-T22)
|
|
self::assertFalse($db->wrote('stock_movement'));
|
|
}
|
|
|
|
public function testInventoryRejectsNegativeCount(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$this->actingPin($db);
|
|
|
|
$response = $this->controller($this->post([
|
|
'_csrf' => $this->csrf, 'actual_quantity' => '-5',
|
|
'pin_email' => 'sam@wakdo.local', 'pin' => '4729',
|
|
], '/admin/ingredients/5/inventory'), $db)->inventory(['id' => '5']);
|
|
|
|
self::assertSame(422, $response->status());
|
|
self::assertFalse($db->wrote('stock_movement'));
|
|
}
|
|
|
|
// --- Visibilite de l'acteur (RG-4) ---
|
|
|
|
public function testMovementsShowActorForManager(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->grantedCodes = ['stock.read', 'stock.manage']; // manager
|
|
$db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']];
|
|
|
|
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
|
|
|
self::assertSame(200, $response->status());
|
|
self::assertStringContainsString('Auteur', $response->body());
|
|
self::assertStringContainsString('Sam K', $response->body()); // nom resolu
|
|
}
|
|
|
|
public function testMovementsHideActorForLineStaff(): void
|
|
{
|
|
$db = $this->permittedDb();
|
|
$db->grantedCodes = ['stock.read']; // ligne : stock.read sans stock.manage
|
|
$db->movementsRows = [['id' => 1, 'ingredient_id' => 5, 'movement_type' => 'restock', 'delta' => 20, 'order_id' => null, 'user_id' => 9, 'note' => null, 'created_at' => '2026-06-17 09:00:00']];
|
|
|
|
$response = $this->controller($this->get('/admin/ingredients/5/movements'), $db)->movements(['id' => '5']);
|
|
|
|
self::assertSame(200, $response->status());
|
|
self::assertStringNotContainsString('Auteur', $response->body()); // colonne masquee (RG-4)
|
|
}
|
|
}
|