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
|
||||
* 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.
|
||||
|
|
|
|||
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