test(stock): tests directs OpenFoodFacts parse() + dashboard alertes (#109)
This commit is contained in:
parent
89488b20b2
commit
6cc762a964
2 changed files with 257 additions and 3 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
168
tests/Unit/Catalogue/OpenFoodFactsGatewayTest.php
Normal file
168
tests/Unit/Catalogue/OpenFoodFactsGatewayTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue