All checks were successful
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 43s
CI / auto-merge (push) Has been skipped
CI / secret-scan (pull_request) Successful in 8s
CI / php-lint (pull_request) Successful in 21s
CI / static-tests (pull_request) Successful in 47s
CI / auto-merge (pull_request) Successful in 5s
PR-A du lot P3 stock. Couche complete de gestion des ingredients et du stock, gardee par des permissions distinctes par operation : - CRUD ingredient (8.8) : ingredient.manage, sans PIN (hors set RG-T13). Conflit d'unicite name + hard-delete bloque par FK RESTRICT -> 409. - RESTOCK (9.1) : stock.manage, sans PIN ; +N packs -> stock += N*pack_size + stock_movement(restock) dans une transaction ; order_id NULL (RG-I6). - INVENTORY_COUNT (9.2) : stock.count + PIN equipier (RG-T13). Ecrit une ligne stock_movement(inventory_correction) MEME si delta=0 (RG-3). Succes -> stock_movement.user_id (acteur resolu par PIN), PAS d'audit_log (RG-T14). Echec PIN -> pin.failed + throttle dans UNE transaction (RG-T22). - READ_STOCK (9.3) : stock.read ; user_id des mouvements visible manager/admin seulement (RG-4). Tests : 239 / 717 assertions verts (WAKDO_DB_TESTS=1, 24 d'integration DB reels), PHPStan L6 propre.
258 lines
11 KiB
PHP
258 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Integration;
|
|
|
|
use PDOException;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Throwable;
|
|
use App\Catalogue\IngredientRepository;
|
|
use App\Core\Config;
|
|
use App\Core\Database;
|
|
|
|
/**
|
|
* Comportement reel d'IngredientRepository contre une vraie MariaDB (schema migre
|
|
* + seede). Auto-skip si WAKDO_DB_TESTS != 1. Ingredient jetable (nom it-ing-*) ;
|
|
* nettoyage en tearDown : on retire d'abord ses mouvements (FK stock_movement
|
|
* RESTRICT) puis l'ingredient.
|
|
*/
|
|
final class IngredientRepositoryDbTest extends TestCase
|
|
{
|
|
private Database $db;
|
|
private string $name = '';
|
|
private int $userId = 0;
|
|
|
|
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->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<string, int|string|null> $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);
|
|
}
|
|
}
|