test(stock): tests directs OpenFoodFacts parse() + dashboard alertes (#109)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 43s
CI / static-tests (push) Successful in 1m17s
CI / js-tests (push) Successful in 49s

This commit is contained in:
Corentin JOGUET 2026-06-25 10:37:06 +02:00
parent 89488b20b2
commit 6cc762a964
2 changed files with 257 additions and 3 deletions

View file

@ -21,6 +21,14 @@ use App\Tests\Support\FakeDatabase;
/** /**
* Stub de StatsRepository : KPIs canned, sans base (les agregats reels sont * Stub de StatsRepository : KPIs canned, sans base (les agregats reels sont
* couverts par StatsRepositoryDbTest). * 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 final class DashStubStatsRepository extends StatsRepository
{ {
@ -39,6 +47,36 @@ final class DashStubStatsRepository extends StatsRepository
return [ return [
'active_total' => 6, 'active_total' => 6,
'bands' => ['normal' => 4, 'low' => 1, 'critical' => 1], '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' => [], 'alerts' => [],
]; ];
} }
@ -56,6 +94,9 @@ final class TestDashboardController extends DashboardController
Database $database, Database $database,
private readonly SessionManager $testSession, private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb, 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); parent::__construct($request, $config, $database);
} }
@ -82,7 +123,7 @@ final class TestDashboardController extends DashboardController
protected function statsRepository(): StatsRepository 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); 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'); $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 private function authedSession(): SessionManager
@ -197,6 +248,41 @@ final class DashboardControllerTest extends TestCase
self::assertStringContainsString('/admin/profile/pin', $body); 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 public function testForbiddenWhenPermissionDenied(): void
{ {
// Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden. // Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden.

View file

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Catalogue;
use PHPUnit\Framework\TestCase;
use App\Catalogue\OpenFoodFactsGateway;
/**
* Parsing pur de la reponse OpenFoodFacts (Cr 3.a.3), isole du reseau :
* parse() prend une string et ne touche jamais cURL, donc tout est testable
* hors-ligne. On verrouille ici le contrat de l'extraction nutritionnelle :
* - guards de structure (JSON, products[0].nutriments) -> 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<string, mixed> $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'));
}
}