db = new Database(new Config()); try { $this->db->fetch('SELECT 1'); } catch (Throwable $exception) { self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); } $this->userId = (int) ($this->db->fetch('SELECT id FROM user ORDER BY id LIMIT 1')['id'] ?? 0); $this->name = 'it-ing-' . bin2hex(random_bytes(4)); } protected function tearDown(): void { if ($this->name === '') { return; } $id = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); if ($id > 0) { $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $id]); $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); } } public function testCreateFindUpdateComputesPctAndBand(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 50, 'stock_capacity' => 100]); self::assertFalse($repo->nameExists($this->name, $id)); // s'exclut lui-meme self::assertTrue($repo->nameExists($this->name)); self::assertFalse($repo->isReferenced($id)); // ni recette ni mouvement $found = $repo->find($id); self::assertNotNull($found); self::assertSame(50, (int) $found['stock_pct']); self::assertSame('normal', (string) $found['stock_band']); // all() porte aussi les champs calcules. $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); self::assertContains($this->name, $names); // update ne touche ni stock_quantity ni is_active (allowlist RG-T16). $repo->update($id, [ 'name' => $this->name, 'unit' => 'sachet', 'stock_capacity' => 200, 'pack_size' => 25, 'pack_label' => 'Sac 25', 'low_stock_pct' => 20, 'critical_stock_pct' => 10, ]); $updated = $repo->find($id); self::assertNotNull($updated); self::assertSame(200, (int) $updated['stock_capacity']); self::assertSame(50, (int) $updated['stock_quantity']); // inchange self::assertSame(25, (int) $updated['stock_pct']); // 50/200 } public function testRestockIncrementsStockAndRecordsMovement(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 50]); $repo->restock($id, 2, $this->userId, 'Livraison A'); self::assertSame(100, (int) ($repo->find($id)['stock_quantity'] ?? -1)); $movements = $repo->movements($id); self::assertCount(1, $movements); self::assertSame('restock', (string) $movements[0]['movement_type']); self::assertSame(100, (int) $movements[0]['delta']); self::assertSame($this->userId, (int) $movements[0]['user_id']); self::assertNull($movements[0]['order_id']); self::assertTrue($repo->isReferenced($id)); // un mouvement reference l'ingredient } public function testInventoryCountRecordsMovementEvenWhenDeltaZero(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 100, 'stock_capacity' => 100]); // Comptage conforme au theorique : delta = 0, MAIS une ligne est ecrite (RG-3). $repo->inventoryCount($id, 100, $this->userId, 'Inventaire mensuel'); $movements = $repo->movements($id); self::assertCount(1, $movements); self::assertSame('inventory_correction', (string) $movements[0]['movement_type']); self::assertSame(0, (int) $movements[0]['delta']); // Comptage divergent : delta negatif, stock cale sur le compte. $repo->inventoryCount($id, 30, $this->userId, null); self::assertSame(30, (int) ($repo->find($id)['stock_quantity'] ?? -1)); $movements = $repo->movements($id); self::assertCount(2, $movements); // plus recent en tete self::assertSame(-70, (int) $movements[0]['delta']); } public function testReferencedIngredientCannotBeHardDeletedButCanBeDeactivated(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 0, 'stock_capacity' => 100, 'pack_size' => 10]); $repo->restock($id, 1, $this->userId, null); // cree un mouvement -> FK RESTRICT $blocked = false; try { $repo->delete($id); } catch (PDOException $exception) { $blocked = (string) $exception->getCode() === '23000'; } self::assertTrue($blocked, 'La suppression dure doit etre bloquee par stock_movement (FK RESTRICT).'); // Repli : soft-delete via is_active. self::assertSame(1, $repo->setActive($id, false)); self::assertSame(0, (int) ($repo->find($id)['is_active'] ?? -1)); } public function testUnreferencedIngredientCanBeHardDeleted(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 5, 'stock_capacity' => 100]); self::assertFalse($repo->isReferenced($id)); self::assertSame(1, $repo->delete($id)); self::assertNull($repo->find($id)); } public function testRestockIsCumulative(): void { $repo = new IngredientRepository($this->db); // Stock initial > 0 + DEUX restock : tue une mutation 'stock = :delta' (set) // au lieu de 'stock += :delta', et un test qui partirait de 0. $id = $this->createIngredient($repo, ['stock_quantity' => 30, 'stock_capacity' => 200, 'pack_size' => 50]); $repo->restock($id, 1, $this->userId, null); // 30 -> 80 $repo->restock($id, 1, $this->userId, null); // 80 -> 130 self::assertSame(130, (int) ($repo->find($id)['stock_quantity'] ?? -1)); $movements = $repo->movements($id); self::assertCount(2, $movements); self::assertSame(50, (int) $movements[0]['delta']); self::assertSame(50, (int) $movements[1]['delta']); } public function testRestockRollsBackWhenMovementInsertFails(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_quantity' => 40, 'stock_capacity' => 100, 'pack_size' => 10]); // user_id inexistant : l'UPDATE stock passe, l'INSERT stock_movement viole // la FK user_id -> la transaction (RG-T08) doit TOUT annuler. $rolledBack = false; try { $repo->restock($id, 2, 2147483647, null); } catch (PDOException $exception) { $rolledBack = (string) $exception->getCode() === '23000'; } self::assertTrue($rolledBack, 'La violation FK user_id doit lever une 23000.'); self::assertSame(40, (int) ($repo->find($id)['stock_quantity'] ?? -1)); // stock intact (rollback) self::assertCount(0, $repo->movements($id)); // aucun mouvement laisse } public function testDuplicateNameViolatesUniqueConstraint(): void { $repo = new IngredientRepository($this->db); $this->createIngredient($repo); // Meme name : la contrainte DB uk_ingredient_name (independante de l'appel // applicatif nameExists) doit rejeter le doublon. $violated = false; try { $this->createIngredient($repo); } catch (PDOException $exception) { $violated = (string) $exception->getCode() === '23000'; } self::assertTrue($violated, 'uk_ingredient_name doit rejeter un doublon (SQLSTATE 23000).'); } public function testMovementsAreBoundedByLimit(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); $repo->restock($id, 1, $this->userId, null); $repo->restock($id, 1, $this->userId, null); $repo->restock($id, 1, $this->userId, null); self::assertCount(3, $repo->movements($id)); // defaut large self::assertCount(2, $repo->movements($id, 2)); // borne LIMIT (RG-3) sur la vraie base } public function testMovementsOrderByCreatedAtBeforeId(): void { $repo = new IngredientRepository($this->db); $id = $this->createIngredient($repo, ['stock_capacity' => 100, 'pack_size' => 1]); $repo->restock($id, 1, $this->userId, 'recent-1'); $repo->restock($id, 1, $this->userId, 'recent-2'); // Mouvement au created_at le plus ANCIEN mais a l'id le plus ELEVE (insere en dernier) : // prouve que created_at DESC prime sur le tie-breaker id DESC. $this->db->execute( "INSERT INTO stock_movement (ingredient_id, movement_type, delta, created_at) " . "VALUES (:id, 'inventory_correction', 0, '2000-01-01 00:00:00')", ['id' => $id], ); $movements = $repo->movements($id); self::assertCount(3, $movements); self::assertSame('2000-01-01 00:00:00', (string) $movements[2]['created_at']); // ancien -> dernier } /** * @param array $overrides */ private function createIngredient(IngredientRepository $repo, array $overrides = []): int { $repo->create([ 'name' => $this->name, 'unit' => 'portion', 'stock_quantity' => (int) ($overrides['stock_quantity'] ?? 0), 'stock_capacity' => (int) ($overrides['stock_capacity'] ?? 100), 'pack_size' => (int) ($overrides['pack_size'] ?? 1), 'pack_label' => $overrides['pack_label'] ?? null, 'low_stock_pct' => (int) ($overrides['low_stock_pct'] ?? 10), 'critical_stock_pct' => (int) ($overrides['critical_stock_pct'] ?? 5), 'is_active' => 1, ]); return (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->name])['id'] ?? 0); } }