diff --git a/tests/Unit/Admin/DashboardControllerTest.php b/tests/Unit/Admin/DashboardControllerTest.php index b1c79b7..205539c 100644 --- a/tests/Unit/Admin/DashboardControllerTest.php +++ b/tests/Unit/Admin/DashboardControllerTest.php @@ -21,6 +21,14 @@ use App\Tests\Support\FakeDatabase; /** * Stub de StatsRepository : KPIs canned, sans base (les agregats reels sont * couverts par StatsRepositoryDbTest). + * + * stockHealth() porte ici un etat de stock NON SAIN (une bande low + une + * critical, avec la liste alerts correspondante) : le dashboard exerce ainsi le + * chemin "stock critique > 0" du fragment admin/dashboard.php. La forme de + * 'alerts' suit le contrat de StatsRepository::stockHealth (name/stock_pct/ + * stock_band), meme si la tuile du dashboard ne consomme que bands.critical + * (le rendu detaille de la liste vit dans admin/stats/index.php, couvert par + * StatsControllerTest). */ final class DashStubStatsRepository extends StatsRepository { @@ -39,6 +47,36 @@ final class DashStubStatsRepository extends StatsRepository return [ 'active_total' => 6, 'bands' => ['normal' => 4, 'low' => 1, 'critical' => 1], + 'alerts' => [ + ['name' => 'Cheddar', 'stock_pct' => 3, 'stock_band' => 'critical'], + ['name' => 'Cornichon', 'stock_pct' => 8, 'stock_band' => 'low'], + ], + ]; + } +} + +/** + * Stub a stock SAIN : aucune bande low/critical, liste d'alerte vide. Sert a + * verifier le pendant negatif de la tuile stock (0 critique -> tag "OK", pas + * "A recommander", classe d'alerte absente). + */ +final class DashHealthyStatsRepository extends StatsRepository +{ + public function counts(): array + { + return [ + 'products' => ['total' => 53, 'available' => 50], + 'categories' => ['total' => 9, 'active' => 9], + 'menus' => ['total' => 13, 'available' => 12], + 'ingredients' => ['total' => 7, 'active' => 7], + ]; + } + + public function stockHealth(): array + { + return [ + 'active_total' => 7, + 'bands' => ['normal' => 7, 'low' => 0, 'critical' => 0], 'alerts' => [], ]; } @@ -56,6 +94,9 @@ final class TestDashboardController extends DashboardController Database $database, private readonly SessionManager $testSession, private readonly FakeDatabase $fakeDb, + // Stub de stats injectable : par defaut l'etat NON SAIN (low+critical), + // surchargeable pour exercer aussi le pendant SAIN de la tuile stock. + private readonly ?StatsRepository $statsStub = null, ) { parent::__construct($request, $config, $database); } @@ -82,7 +123,7 @@ final class TestDashboardController extends DashboardController protected function statsRepository(): StatsRepository { - return new DashStubStatsRepository($this->fakeDb); + return $this->statsStub ?? new DashStubStatsRepository($this->fakeDb); } /** @@ -125,11 +166,21 @@ final class DashboardControllerTest extends TestCase putenv($key . '=' . $value); } - private function controller(SessionManager $session, FakeDatabase $db): TestDashboardController + private function controller(SessionManager $session, FakeDatabase $db, ?StatsRepository $statsStub = null): TestDashboardController { $request = new Request('GET', '/admin/dashboard', [], [], '', '203.0.113.5'); - return new TestDashboardController($request, new Config(), new Database(new Config()), $session, $db); + return new TestDashboardController($request, new Config(), new Database(new Config()), $session, $db, $statsStub); + } + + private function authedAdminDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->permissionCodes = ['product.read', 'user.read']; + + return $db; } private function authedSession(): SessionManager @@ -197,6 +248,41 @@ final class DashboardControllerTest extends TestCase self::assertStringContainsString('/admin/profile/pin', $body); } + public function testRendersCriticalStockTileWhenStockNotHealthy(): void + { + // CIBLE F6 : exerce le chemin "stock NON sain" du fragment dashboard. + // Le stub par defaut (DashStubStatsRepository) renvoie bands.critical = 1 + // et une liste alerts NON VIDE. Le fragment admin/dashboard.php derive + // $nCritical de bands.critical et bascule la tuile en mode alerte des que + // > 0 (classe "alert", tag "A recommander", valeur affichee). + $response = $this->controller($this->authedSession(), $this->authedAdminDb())->index(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + // La tuile "Stock critique" affiche le compteur de la bande critical. + self::assertStringContainsString('Stock critique', $body); + // Branche $nCritical > 0 : tag d'action + classe d'alerte sur la tuile. + self::assertStringContainsString('A recommander', $body); + self::assertStringContainsString('tile alert', $body); + // Pendant negatif : l'etat sain ("OK") ne doit pas etre rendu ici. + self::assertStringNotContainsString('>OK<', $body); + } + + public function testRendersHealthyStockTileWhenNoCriticalIngredient(): void + { + // Pendant SAIN : 0 critique -> branche $nCritical === 0 (tag "OK", pas de + // classe d'alerte). Verrouille les deux cotes de la condition de la tuile. + $stub = new DashHealthyStatsRepository(new FakeDatabase()); + $body = $this->controller($this->authedSession(), $this->authedAdminDb(), $stub)->index()->body(); + + self::assertStringContainsString('Stock critique', $body); + // Tag exact (>OK<) plutot que 'OK' nu, qui pourrait matcher du contenu + // sans rapport (cookie, lookup, etc.). + self::assertStringContainsString('>OK<', $body); + self::assertStringNotContainsString('A recommander', $body); + self::assertStringNotContainsString('tile alert', $body); + } + public function testForbiddenWhenPermissionDenied(): void { // Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden. diff --git a/tests/Unit/Catalogue/OpenFoodFactsGatewayTest.php b/tests/Unit/Catalogue/OpenFoodFactsGatewayTest.php new file mode 100644 index 0000000..5f5b313 --- /dev/null +++ b/tests/Unit/Catalogue/OpenFoodFactsGatewayTest.php @@ -0,0 +1,168 @@ + null ; + * - guard is_numeric sur energy-kcal_100g -> null ; + * - garde de domaine sur la valeur, alignee sur la colonne energy_kcal_100g + * (SMALLINT UNSIGNED, plage 0..65535 ; migration 0005) ; + * - forme du tableau retourne. + * + * Note de comportement (a confirmer cote produit) : le code NE CLAMPE PAS une + * valeur hors plage. Une valeur < 0 ou > 65535 fait retourner null (rejet), + * pas un rabotage a 0 ou 65535. Les cas ci-dessous testent ce comportement + * REEL ; voir testRejectsValueAboveSmallintMax / testRejectsNegativeValue. + */ +final class OpenFoodFactsGatewayTest extends TestCase +{ + private function gateway(): OpenFoodFactsGateway + { + return new OpenFoodFactsGateway(); + } + + /** + * Encode un corps de reponse OpenFoodFacts minimal autour d'une valeur de + * nutriments, pour eviter de repeter l'enveloppe products[0] dans chaque test. + * + * @param array $nutriments + */ + private function bodyWithNutriments(array $nutriments): string + { + $json = json_encode([ + 'products' => [ + ['product_name' => 'Test', 'nutriments' => $nutriments], + ], + ]); + + // json_encode d'un tableau structure ne peut pas echouer ici ; le cast + // satisfait l'analyse statique (parse() exige une string). + return (string) $json; + } + + public function testExtractsKcalFromValidBody(): void + { + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => 250])); + + self::assertSame(['energy_kcal_100g' => 250, 'source' => 'OpenFoodFacts'], $result); + } + + public function testRoundsFloatKcalToNearestInteger(): void + { + // (int) round((float) $kcal) : 254.6 -> 255 (round half away from zero). + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => 254.6])); + + self::assertNotNull($result); + self::assertSame(255, $result['energy_kcal_100g']); + } + + public function testAcceptsNumericStringKcal(): void + { + // is_numeric() accepte une chaine numerique ("314") ; l'API peut renvoyer + // la valeur encodee en string selon le produit. + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => '314'])); + + self::assertNotNull($result); + self::assertSame(314, $result['energy_kcal_100g']); + } + + public function testAcceptsUpperBoundValue(): void + { + // 65535 = max SMALLINT UNSIGNED : DANS la plage (la garde est < 0 || > 65535), + // donc accepte tel quel. + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => 65535])); + + self::assertNotNull($result); + self::assertSame(65535, $result['energy_kcal_100g']); + } + + public function testRejectsValueAboveSmallintMax(): void + { + // CIBLE 1 cas (2). Comportement REEL : > 65535 n'est PAS rabote a 65535, + // il est REJETE (return null). La garde protege la colonne SMALLINT + // UNSIGNED en refusant une valeur qui ne tiendrait pas (migration 0005). + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => 70000])); + + self::assertNull($result); + } + + public function testRejectsNegativeValue(): void + { + // CIBLE 1 cas (3). Comportement REEL : une valeur negative n'est PAS + // ramenee a 0, elle est REJETEE (return null). Un apport energetique + // negatif est aberrant et ne tiendrait pas dans un UNSIGNED. + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => -5])); + + self::assertNull($result); + } + + public function testReturnsNullWhenKcalNotNumeric(): void + { + // CIBLE 1 cas (4). "N/A" echoue is_numeric() -> null (champ present mais + // non exploitable). + $result = $this->gateway()->parse($this->bodyWithNutriments(['energy-kcal_100g' => 'N/A'])); + + self::assertNull($result); + } + + public function testReturnsNullWhenKcalFieldAbsent(): void + { + // nutriments present mais sans la cle energy-kcal_100g : le coalesce ?? null + // donne null, puis is_numeric(null) est faux -> null. + $result = $this->gateway()->parse($this->bodyWithNutriments(['fat_100g' => 12])); + + self::assertNull($result); + } + + public function testReturnsNullWhenNutrimentsAbsent(): void + { + // CIBLE 1 cas (5). products[0] sans nutriments -> guard isset() faux -> null. + $body = (string) json_encode(['products' => [['product_name' => 'Test']]]); + + self::assertNull($this->gateway()->parse($body)); + } + + public function testReturnsNullWhenNutrimentsNotArray(): void + { + // nutriments present mais scalaire : le guard is_array() le rejette. + $body = (string) json_encode(['products' => [['nutriments' => 'oops']]]); + + self::assertNull($this->gateway()->parse($body)); + } + + public function testReturnsNullWhenNoProducts(): void + { + // Recherche sans resultat : products vide -> products[0] absent -> null. + $body = (string) json_encode(['products' => []]); + + self::assertNull($this->gateway()->parse($body)); + } + + public function testReturnsNullOnInvalidJson(): void + { + // CIBLE 1 cas (6). json_decode rend null sur un corps mal forme ; + // is_array(null) est faux -> null (tolerance aux pannes, cf. classe). + self::assertNull($this->gateway()->parse('{not valid json')); + } + + public function testReturnsNullOnEmptyBody(): void + { + // Corps vide : json_decode('') rend null -> is_array faux -> null. + self::assertNull($this->gateway()->parse('')); + } + + public function testReturnsNullWhenJsonIsScalar(): void + { + // Un JSON valide mais scalaire (pas un objet/tableau associatif) : + // is_array(decode) est faux -> null. + self::assertNull($this->gateway()->parse('42')); + } +}