feat(api): P4 chunk 2 - read API catalogue borne (categories/produits/menus) (#60)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 18s
CI / static-tests (push) Successful in 44s
CI / js-tests (push) Successful in 27s

This commit is contained in:
Corentin JOGUET 2026-06-18 16:10:36 +02:00
parent 9bc0140b9a
commit a35db88d2f
8 changed files with 973 additions and 0 deletions

View file

@ -44,6 +44,21 @@ final class CategoryRepository
);
}
/**
* Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : seulement
* les categories actives, triees comme la liste back-office. Le flag is_active
* n'est pas selectionne (toutes celles-ci le sont) -> rien d'inutile a la borne.
*
* @return array<int, array<string, mixed>>
*/
public function activeForCatalogue(): array
{
return $this->db->fetchAll(
'SELECT id, name, slug, image_path, display_order '
. 'FROM category WHERE is_active = 1 ORDER BY display_order, name',
);
}
public function nameExists(string $name, int $exceptId = 0): bool
{
return $this->db->fetch(

View file

@ -58,6 +58,46 @@ final class MenuRepository
);
}
/**
* Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : menus
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
* Projection enrichie (description, image_path) absente de all() back-office.
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
* RG-T21, differe au seed des recettes.
*
* @return array<int, array<string, mixed>>
*/
public function availableForCatalogue(): array
{
return $this->db->fetchAll(
'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.description, '
. 'm.price_normal_cents, m.price_maxi_cents, m.image_path, m.display_order '
. 'FROM menu m JOIN category c ON c.id = m.category_id '
. 'WHERE m.is_available = 1 AND c.is_active = 1 '
. 'ORDER BY m.display_order, m.name',
);
}
/**
* Detail menu pour la borne : meme projection que la liste, seulement si le
* menu est disponible en categorie active ; sinon null (le controleur rend
* 404). Les slots sont charges a part (slotsWithOptions) puis assembles par le
* controleur.
*
* @return array<string, mixed>|null
*/
public function findForCatalogue(int $id): ?array
{
return $this->db->fetch(
'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.description, '
. 'm.price_normal_cents, m.price_maxi_cents, m.image_path, m.display_order '
. 'FROM menu m JOIN category c ON c.id = m.category_id '
. 'WHERE m.id = :id AND m.is_available = 1 AND c.is_active = 1',
['id' => $id],
);
}
/**
* Slots d'un menu (ordonnes), chacun avec la liste de ses product_id eligibles.
* Une seule requete (LEFT JOIN) regroupee en PHP par slot.

View file

@ -54,6 +54,47 @@ final class ProductRepository
);
}
/**
* Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : produits
* commandables seulement (is_available = 1) ET dont la categorie est active
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
* categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
*
* @return array<int, array<string, mixed>>
*/
public function availableForCatalogue(): array
{
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, '
. 'p.image_path, p.display_order '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'WHERE p.is_available = 1 AND c.is_active = 1 '
. 'ORDER BY p.display_order, p.name',
);
}
/**
* Detail produit pour la borne : la meme projection que la liste, et seulement
* si le produit est commandable (is_available = 1) en categorie active ; sinon
* null (le controleur rend 404). Un produit retire ou en categorie masquee est
* donc invisible meme par lien direct.
*
* @return array<string, mixed>|null
*/
public function findForCatalogue(int $id): ?array
{
return $this->db->fetch(
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, '
. 'p.image_path, p.display_order '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'WHERE p.id = :id AND p.is_available = 1 AND c.is_active = 1',
['id' => $id],
);
}
public function categoryExists(int $categoryId): bool
{
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;

View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Catalogue\CategoryRepository;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\Controller;
use App\Core\DatabaseInterface;
use App\Core\Response;
/**
* API publique de LECTURE du catalogue pour la borne kiosk (P4, lecture
* catalogue, docs/api/conventions.md section 5.2). Anonyme : la borne consulte
* sans session. Lecture seule, aucune mutation.
*
* La borne ne voit que le COMMANDABLE : categories actives, produits disponibles
* dont la categorie est active (filtrage en SQL cote repository). Les champs
* suivent le dictionnaire (snake_case, prix en centimes, section 8.1) ; le
* rapprochement vers la forme heritee de la borne se fait en un point unique,
* data.js (section 8.3). vat_rate n'est PAS expose (calcul fiscal cote serveur).
*
* Le typage de sortie est explicite (present*) : la valeur JSON ne depend pas du
* mode de fetch PDO (price_cents reste un entier, image_path reste nullable),
* meme discipline que OrderController::present.
*
* Enveloppe standard : {data} (collection avec total) / {data:null, error:{code,
* message}}. Non `final` : les tests sous-classent pour injecter un acces BDD
* double via le hook db().
*/
class CatalogueController extends Controller
{
/**
* @param array<string, string> $params
*/
public function categories(array $params = []): Response
{
$rows = array_map(
fn (array $row): array => $this->presentCategory($row),
$this->categoriesRepo()->activeForCatalogue(),
);
return $this->json(['data' => $rows, 'total' => count($rows)]);
}
/**
* @param array<string, string> $params
*/
public function products(array $params = []): Response
{
$rows = array_map(
fn (array $row): array => $this->presentProduct($row),
$this->productsRepo()->availableForCatalogue(),
);
return $this->json(['data' => $rows, 'total' => count($rows)]);
}
/**
* @param array<string, string> $params
*/
public function product(array $params = []): Response
{
$id = (int) ($params['id'] ?? 0);
$row = $id > 0 ? $this->productsRepo()->findForCatalogue($id) : null;
if ($row === null) {
return $this->json(
['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Produit introuvable.']],
404,
);
}
return $this->json(['data' => $this->presentProduct($row)]);
}
/**
* @param array<string, string> $params
*/
public function menus(array $params = []): Response
{
$rows = array_map(
fn (array $row): array => $this->presentMenu($row),
$this->menusRepo()->availableForCatalogue(),
);
return $this->json(['data' => $rows, 'total' => count($rows)]);
}
/**
* @param array<string, string> $params
*/
public function menu(array $params = []): Response
{
$id = (int) ($params['id'] ?? 0);
$repo = $this->menusRepo();
$row = $id > 0 ? $repo->findForCatalogue($id) : null;
if ($row === null) {
return $this->json(
['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Menu introuvable.']],
404,
);
}
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
return $this->json(['data' => $menu]);
}
protected function categoriesRepo(): CategoryRepository
{
return new CategoryRepository($this->db());
}
protected function productsRepo(): ProductRepository
{
return new ProductRepository($this->db());
}
protected function menusRepo(): MenuRepository
{
return new MenuRepository($this->db());
}
/**
* Acces BDD comme DatabaseInterface (seam de test). Database l'implemente.
*/
protected function db(): DatabaseInterface
{
return $this->database;
}
/**
* @param array<string, mixed> $row
* @return array{id: int, name: string, slug: string, image_path: ?string, display_order: int}
*/
private function presentCategory(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'slug' => (string) ($row['slug'] ?? ''),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
];
}
/**
* @param array<string, mixed> $row
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int}
*/
private function presentProduct(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'category_id' => (int) ($row['category_id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $this->nullableString($row['description'] ?? null),
'price_cents' => (int) ($row['price_cents'] ?? 0),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
];
}
/**
* @param array<string, mixed> $row
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
*/
private function presentMenu(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'category_id' => (int) ($row['category_id'] ?? 0),
'burger_product_id' => (int) ($row['burger_product_id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'description' => $this->nullableString($row['description'] ?? null),
'price_normal_cents' => (int) ($row['price_normal_cents'] ?? 0),
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
];
}
/**
* Slots de composition d'un menu pour la borne. MenuRepository::slotsWithOptions
* a deja groupe les options par slot et type les valeurs ; on expose is_required
* en vrai booleen (plus naturel pour le client JS) et on garde la liste d'ids
* de produits eligibles (la borne resout les libelles via /api/products).
*
* @param list<array{id: int, name: string, slot_type: string, is_required: int, display_order: int, option_product_ids: list<int>}> $slots
* @return list<array{id: int, name: string, slot_type: string, is_required: bool, display_order: int, option_product_ids: list<int>}>
*/
private function presentSlots(array $slots): array
{
return array_map(
static fn (array $slot): array => [
'id' => $slot['id'],
'name' => $slot['name'],
'slot_type' => $slot['slot_type'],
'is_required' => $slot['is_required'] !== 0,
'display_order' => $slot['display_order'],
'option_product_ids' => $slot['option_product_ids'],
],
$slots,
);
}
/**
* Preserve NULL (colonne nullable) tout en restant strictement type : un
* scalaire devient une chaine, tout le reste (null, tableau) devient null.
*/
private function nullableString(mixed $value): ?string
{
return is_scalar($value) ? (string) $value : null;
}
}

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
use App\Auth\SessionManager;
use App\Controllers\AuthController;
use App\Controllers\CatalogueController;
use App\Controllers\CategoryController;
use App\Controllers\DashboardController;
use App\Controllers\HealthController;
@ -78,6 +79,18 @@ try {
$router->add('POST', '/api/orders', [OrderController::class, 'create']);
$router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']);
// Lecture catalogue borne (P4, docs/api/conventions.md section 5.2). API publique
// kiosk, ANONYME : la borne consulte sans session. Lecture seule ; ne sert que le
// commandable (categories actives, produits disponibles en categorie active).
// {id} = un seul segment ; /api/products (collection) et /api/products/{id}
// (unitaire) ne se chevauchent pas.
$router->add('GET', '/api/categories', [CatalogueController::class, 'categories']);
$router->add('GET', '/api/products', [CatalogueController::class, 'products']);
$router->add('GET', '/api/products/{id}', [CatalogueController::class, 'product']);
// Menus composes : liste legere + detail avec slots (B1 burger impose, B2 Normal/Maxi).
$router->add('GET', '/api/menus', [CatalogueController::class, 'menus']);
$router->add('GET', '/api/menus/{id}', [CatalogueController::class, 'menu']);
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
// Tableau de bord statistiques (stats.read) : landing du role manager. KPIs

View file

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Catalogue\CategoryRepository;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\Config;
use App\Core\Database;
/**
* Filtres de lecture catalogue borne contre une vraie MariaDB (schema migre).
* Auto-skip si WAKDO_DB_TESTS != 1. Verifie que la borne ne voit que le
* commandable : categorie active, produit disponible ET en categorie active.
* Fixtures uniques (it-cat-<suffix>* / IT Prod <suffix>*), nettoyage FK-safe
* (produits avant categories) en tearDown.
*/
final class CatalogueReadDbTest extends TestCase
{
private Database $db;
private string $suffix = '';
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->suffix = bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
if ($this->suffix === '') {
return;
}
// Ordre FK : menus (burger_product_id / category_id RESTRICT) d'abord -- le
// delete cascade menu_slot + menu_slot_option ; puis produits, puis categories.
$this->db->execute('DELETE FROM menu WHERE name LIKE :m', ['m' => 'IT Menu ' . $this->suffix . '%']);
$this->db->execute('DELETE FROM product WHERE name LIKE :p', ['p' => 'IT Prod ' . $this->suffix . '%']);
$this->db->execute('DELETE FROM category WHERE slug LIKE :s', ['s' => 'it-cat-' . $this->suffix . '%']);
}
public function testCatalogueReadFiltersToOrderable(): void
{
$categories = new CategoryRepository($this->db);
$products = new ProductRepository($this->db);
$activeSlug = 'it-cat-' . $this->suffix . '-a';
$inactiveSlug = 'it-cat-' . $this->suffix . '-b';
$categories->create(['name' => 'IT Cat A ' . $this->suffix, 'slug' => $activeSlug, 'image_path' => null, 'display_order' => 90, 'is_active' => 1]);
$categories->create(['name' => 'IT Cat B ' . $this->suffix, 'slug' => $inactiveSlug, 'image_path' => null, 'display_order' => 91, 'is_active' => 0]);
$activeCatId = $this->idOfCategory($activeSlug);
$inactiveCatId = $this->idOfCategory($inactiveSlug);
self::assertGreaterThan(0, $activeCatId);
self::assertGreaterThan(0, $inactiveCatId);
$availName = 'IT Prod ' . $this->suffix . ' avail';
$hiddenName = 'IT Prod ' . $this->suffix . ' hidden';
$inactCatName = 'IT Prod ' . $this->suffix . ' inactcat';
$products->create($this->productData($availName, $activeCatId, 1));
$products->create($this->productData($hiddenName, $activeCatId, 0));
$products->create($this->productData($inactCatName, $inactiveCatId, 1));
// Categories : la borne ne voit que l'active, sans le flag is_active.
$catSlugs = array_map(static fn (array $r): string => (string) ($r['slug'] ?? ''), $categories->activeForCatalogue());
self::assertContains($activeSlug, $catSlugs);
self::assertNotContains($inactiveSlug, $catSlugs);
$sample = $categories->activeForCatalogue()[0] ?? [];
self::assertArrayNotHasKey('is_active', $sample);
// Produits : seul le disponible-en-categorie-active remonte.
$names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $products->availableForCatalogue());
self::assertContains($availName, $names);
self::assertNotContains($hiddenName, $names); // is_available = 0
self::assertNotContains($inactCatName, $names); // categorie inactive
// availableForCatalogue n'expose pas vat_rate.
$availRow = $this->rowByName($products->availableForCatalogue(), $availName);
self::assertNotNull($availRow);
self::assertArrayNotHasKey('vat_rate', $availRow);
// findForCatalogue : le disponible OK ; indispo / categorie inactive / id 0 -> null.
$availId = $this->idOfProduct($availName);
$hiddenId = $this->idOfProduct($hiddenName);
$inactCatProdId = $this->idOfProduct($inactCatName);
self::assertNotNull($products->findForCatalogue($availId));
self::assertNull($products->findForCatalogue($hiddenId));
self::assertNull($products->findForCatalogue($inactCatProdId));
self::assertNull($products->findForCatalogue(0));
}
public function testMenuReadFiltersAndSlots(): void
{
$categories = new CategoryRepository($this->db);
$products = new ProductRepository($this->db);
$menus = new MenuRepository($this->db);
$activeSlug = 'it-cat-' . $this->suffix . '-ma';
$inactiveSlug = 'it-cat-' . $this->suffix . '-mb';
$categories->create(['name' => 'IT Cat MA ' . $this->suffix, 'slug' => $activeSlug, 'image_path' => null, 'display_order' => 92, 'is_active' => 1]);
$categories->create(['name' => 'IT Cat MB ' . $this->suffix, 'slug' => $inactiveSlug, 'image_path' => null, 'display_order' => 93, 'is_active' => 0]);
$activeCatId = $this->idOfCategory($activeSlug);
$inactiveCatId = $this->idOfCategory($inactiveSlug);
// Burger impose + un produit d'option (FK RESTRICT), tous deux disponibles.
$products->create($this->productData('IT Prod ' . $this->suffix . ' burger', $activeCatId, 1));
$products->create($this->productData('IT Prod ' . $this->suffix . ' opt', $activeCatId, 1));
$burgerId = $this->idOfProduct('IT Prod ' . $this->suffix . ' burger');
$optId = $this->idOfProduct('IT Prod ' . $this->suffix . ' opt');
$slots = [
['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'options' => [$optId]],
// Slot sans option eligible : doit remonter (LEFT JOIN) avec une liste vide.
['name' => 'Extra', 'slot_type' => 'extra', 'is_required' => 0, 'display_order' => 2, 'options' => []],
];
$availName = 'IT Menu ' . $this->suffix . ' avail';
$hiddenName = 'IT Menu ' . $this->suffix . ' hidden';
$inactCatName = 'IT Menu ' . $this->suffix . ' inactcat';
$availMenuId = $menus->create($this->menuData($availName, $activeCatId, $burgerId, 1), $slots);
$menus->create($this->menuData($hiddenName, $activeCatId, $burgerId, 0), []);
$menus->create($this->menuData($inactCatName, $inactiveCatId, $burgerId, 1), []);
// Liste : seul le disponible-en-categorie-active remonte ; projection enrichie
// (description/image_path) ; sans le flag is_available.
$names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $menus->availableForCatalogue());
self::assertContains($availName, $names);
self::assertNotContains($hiddenName, $names); // is_available = 0
self::assertNotContains($inactCatName, $names); // categorie inactive
$availRow = $this->rowByName($menus->availableForCatalogue(), $availName);
self::assertNotNull($availRow);
self::assertArrayHasKey('description', $availRow);
self::assertArrayHasKey('image_path', $availRow);
self::assertArrayNotHasKey('is_available', $availRow);
// findForCatalogue : disponible OK, indisponible -> null.
self::assertNotNull($menus->findForCatalogue($availMenuId));
self::assertNull($menus->findForCatalogue($this->idOfMenu($hiddenName)));
// Slots groupes : le slot drink porte son option, le slot extra (sans option)
// remonte avec une liste VIDE (et non [0]). Ordre par display_order.
$slotsOut = $menus->slotsWithOptions($availMenuId);
self::assertCount(2, $slotsOut);
self::assertSame('drink', $slotsOut[0]['slot_type']);
self::assertSame([$optId], $slotsOut[0]['option_product_ids']);
self::assertSame('extra', $slotsOut[1]['slot_type']);
self::assertSame([], $slotsOut[1]['option_product_ids']);
}
private function idOfCategory(string $slug): int
{
return (int) ($this->db->fetch('SELECT id FROM category WHERE slug = :s', ['s' => $slug])['id'] ?? 0);
}
private function idOfMenu(string $name): int
{
return (int) ($this->db->fetch('SELECT id FROM menu WHERE name = :n', ['n' => $name])['id'] ?? 0);
}
private function idOfProduct(string $name): int
{
return (int) ($this->db->fetch('SELECT id FROM product WHERE name = :n', ['n' => $name])['id'] ?? 0);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>|null
*/
private function rowByName(array $rows, string $name): ?array
{
foreach ($rows as $row) {
if (($row['name'] ?? null) === $name) {
return $row;
}
}
return null;
}
/**
* @return array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
*/
private function productData(string $name, int $categoryId, int $available): array
{
return [
'category_id' => $categoryId,
'name' => $name,
'description' => null,
'price_cents' => 500,
'vat_rate' => 100,
'image_path' => null,
'is_available' => $available,
'display_order' => 10,
];
}
/**
* @return array{category_id: int, burger_product_id: int, name: string, price_normal_cents: int, price_maxi_cents: int, is_available: int, display_order: int}
*/
private function menuData(string $name, int $categoryId, int $burgerId, int $available): array
{
return [
'category_id' => $categoryId,
'burger_product_id' => $burgerId,
'name' => $name,
'price_normal_cents' => 990,
'price_maxi_cents' => 1190,
'is_available' => $available,
'display_order' => 10,
];
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Core\DatabaseInterface;
use RuntimeException;
/**
* Double de DatabaseInterface dedie aux lectures catalogue de la borne
* (CatalogueController). Les lignes sont scriptees par "boutons" types
* (categoriesRows, productsRows, productRow) ; les lectures sont tracees pour
* asserter le court-circuit (id non numerique = aucun aller-retour BDD).
*
* Lecture seule a dessein : ce controleur ne doit jamais ecrire. execute() et
* transaction() levent donc une exception -> un test vire au rouge si une
* mutation s'y glisse (garde du contrat read-only).
*/
final class FakeCatalogueDatabase implements DatabaseInterface
{
/**
* Lignes renvoyees par CategoryRepository::activeForCatalogue().
*
* @var list<array<string, mixed>>
*/
public array $categoriesRows = [];
/**
* Lignes renvoyees par ProductRepository::availableForCatalogue().
*
* @var list<array<string, mixed>>
*/
public array $productsRows = [];
/**
* Ligne renvoyee par ProductRepository::findForCatalogue() ; null = absent /
* indisponible / categorie inactive.
*
* @var array<string, mixed>|null
*/
public ?array $productRow = null;
/**
* Lignes renvoyees par MenuRepository::availableForCatalogue().
*
* @var list<array<string, mixed>>
*/
public array $menusRows = [];
/**
* Ligne renvoyee par MenuRepository::findForCatalogue() ; null = absent /
* indisponible / categorie inactive.
*
* @var array<string, mixed>|null
*/
public ?array $menuRow = null;
/**
* Lignes brutes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions().
*
* @var list<array<string, mixed>>
*/
public array $menuSlotRows = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
* @var list<array{sql: string, params: array<string|int, mixed>}>
*/
public array $reads = [];
public function fetch(string $sql, array $params = []): ?array
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'WHERE p.id = :id')) {
return $this->productRow;
}
if (str_contains($sql, 'FROM menu m JOIN category') && str_contains($sql, 'WHERE m.id = :id')) {
return $this->menuRow;
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
if (str_contains($sql, 'FROM category WHERE is_active = 1')) {
return $this->categoriesRows;
}
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'WHERE p.is_available = 1')) {
return $this->productsRows;
}
if (str_contains($sql, 'FROM menu m JOIN category') && str_contains($sql, 'WHERE m.is_available = 1')) {
return $this->menusRows;
}
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->menuSlotRows;
}
return [];
}
public function execute(string $sql, array $params = []): int
{
throw new RuntimeException('Lecture seule : execute() interdit sur le catalogue borne.');
}
public function transaction(callable $fn): void
{
throw new RuntimeException('Lecture seule : transaction() interdit sur le catalogue borne.');
}
}

View file

@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Catalogue;
use PHPUnit\Framework\TestCase;
use App\Controllers\CatalogueController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeCatalogueDatabase;
/**
* Sous-classe de test : redefinit db() pour injecter le double catalogue, sans
* base reelle. Les repos de lecture sont alors construits sur ce double, ce qui
* exerce le cablage controleur -> repository -> SQL.
*/
final class TestCatalogueController extends CatalogueController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly FakeCatalogueDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function db(): DatabaseInterface
{
return $this->fakeDb;
}
}
final class CatalogueControllerTest extends TestCase
{
private function controller(FakeCatalogueDatabase $db, string $path = '/api/categories'): TestCatalogueController
{
$request = new Request('GET', $path, [], [], '', '203.0.113.5');
return new TestCatalogueController($request, new Config(), new Database(new Config()), $db);
}
/**
* @return array<string, mixed>
*/
private function decode(string $body): array
{
$data = json_decode($body, true);
self::assertIsArray($data);
return $data;
}
public function testCategoriesReturnsActiveCollectionEnvelope(): void
{
$db = new FakeCatalogueDatabase();
// Entiers scriptes en CHAINE (comme PDO peut les rendre) + champ is_active
// parasite : le controleur doit caster en int ET ne pas le laisser fuiter.
$db->categoriesRows = [
['id' => '3', 'name' => 'Burgers', 'slug' => 'burgers', 'image_path' => 'burgers.png', 'display_order' => '2', 'is_active' => '1'],
['id' => '1', 'name' => 'Menus', 'slug' => 'menus', 'image_path' => null, 'display_order' => '1', 'is_active' => '1'],
];
$response = $this->controller($db, '/api/categories')->categories();
self::assertSame(200, $response->status());
self::assertSame('application/json; charset=utf-8', $response->header('Content-Type'));
$payload = $this->decode($response->body());
self::assertSame(2, $payload['total'] ?? null);
self::assertIsArray($payload['data']);
$first = $payload['data'][0];
self::assertSame(['id', 'name', 'slug', 'image_path', 'display_order'], array_keys($first));
self::assertSame(3, $first['id']); // chaine '3' -> int 3
self::assertSame(2, $first['display_order']); // chaine '2' -> int 2
self::assertSame('burgers.png', $first['image_path']);
self::assertNull($payload['data'][1]['image_path']); // null preserve
self::assertArrayNotHasKey('is_active', $first); // pas de fuite
}
public function testCategoriesEmptyReturnsEmptyCollection(): void
{
$db = new FakeCatalogueDatabase();
$response = $this->controller($db, '/api/categories')->categories();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame([], $payload['data']);
self::assertSame(0, $payload['total']);
}
public function testProductsReturnsAvailableCollectionWithoutVatRate(): void
{
$db = new FakeCatalogueDatabase();
$db->productsRows = [
[
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
'description' => 'Pain, steak, cheddar', 'price_cents' => '890',
'vat_rate' => '100', 'image_path' => 'cheese.png', 'display_order' => '1',
],
];
$response = $this->controller($db, '/api/products')->products();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame(1, $payload['total']);
$product = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order'],
array_keys($product),
);
self::assertSame(12, $product['id']);
self::assertSame(3, $product['category_id']);
self::assertSame(890, $product['price_cents']); // chaine -> int
self::assertArrayNotHasKey('vat_rate', $product); // fiscal interne, non expose
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
}
public function testProductDetailReturnsData(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
'description' => null, 'price_cents' => '890', 'vat_rate' => '100',
'image_path' => null, 'display_order' => '1',
];
$response = $this->controller($db, '/api/products/12')->product(['id' => '12']);
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
$product = $payload['data'];
self::assertSame(12, $product['id']);
self::assertSame(890, $product['price_cents']);
self::assertNull($product['description']);
self::assertArrayNotHasKey('vat_rate', $product);
// L'id a bien ete lie a la lecture, converti en entier (le repo a recu :id = 12).
self::assertSame(12, $db->reads[0]['params']['id'] ?? null);
}
public function testProductDetailUnknownReturns404(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = null; // absent / indisponible / categorie inactive
$response = $this->controller($db, '/api/products/999')->product(['id' => '999']);
self::assertSame(404, $response->status());
$payload = $this->decode($response->body());
self::assertNull($payload['data']);
self::assertSame('NOT_FOUND', $payload['error']['code'] ?? null);
}
public function testProductDetailNonNumericReturns404WithoutQuery(): void
{
$db = new FakeCatalogueDatabase();
$response = $this->controller($db, '/api/products/abc')->product(['id' => 'abc']);
self::assertSame(404, $response->status());
// id non numerique -> 0 -> court-circuit : aucun aller-retour BDD.
self::assertSame([], $db->reads);
}
public function testMenusReturnsLightCollectionWithoutSlots(): void
{
$db = new FakeCatalogueDatabase();
$db->menusRows = [
[
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5',
'name' => 'Menu Maxi Best Of', 'description' => 'Burger + frites + boisson',
'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => 'menu.png', 'display_order' => '1', 'is_available' => '1',
],
];
$response = $this->controller($db, '/api/menus')->menus();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame(1, $payload['total']);
$menu = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
array_keys($menu),
);
self::assertSame(1, $menu['id']);
self::assertSame(5, $menu['burger_product_id']);
self::assertSame(990, $menu['price_normal_cents']);
self::assertSame(1190, $menu['price_maxi_cents']);
self::assertArrayNotHasKey('slots', $menu); // liste legere : pas de slots
self::assertArrayNotHasKey('is_available', $menu); // toujours dispo ici
self::assertArrayNotHasKey('vat_rate', $menu);
}
public function testMenuDetailReturnsDataWithSlots(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = [
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5',
'name' => 'Menu Maxi Best Of', 'description' => null,
'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => null, 'display_order' => '1',
];
// Lignes brutes (LEFT JOIN) : slot 7 a deux options, slot 8 une, ordre par slot.
$db->menuSlotRows = [
['id' => '7', 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => '1', 'display_order' => '1', 'product_id' => '27'],
['id' => '7', 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => '1', 'display_order' => '1', 'product_id' => '28'],
['id' => '8', 'name' => 'Sauce', 'slot_type' => 'sauce', 'is_required' => '0', 'display_order' => '2', 'product_id' => '40'],
];
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
$menu = $payload['data'];
self::assertSame(1, $menu['id']);
self::assertSame(5, $menu['burger_product_id']);
self::assertArrayNotHasKey('vat_rate', $menu);
self::assertIsArray($menu['slots']);
self::assertCount(2, $menu['slots']);
$drink = $menu['slots'][0];
self::assertSame(['id', 'name', 'slot_type', 'is_required', 'display_order', 'option_product_ids'], array_keys($drink));
self::assertSame(7, $drink['id']);
self::assertSame('drink', $drink['slot_type']);
self::assertTrue($drink['is_required']); // tinyint 1 -> bool true
self::assertSame([27, 28], $drink['option_product_ids']); // ints groupes
$sauce = $menu['slots'][1];
self::assertFalse($sauce['is_required']); // tinyint 0 -> bool false
self::assertSame([40], $sauce['option_product_ids']);
}
public function testMenuDetailUnknownReturns404(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = null;
$response = $this->controller($db, '/api/menus/999')->menu(['id' => '999']);
self::assertSame(404, $response->status());
$payload = $this->decode($response->body());
self::assertNull($payload['data']);
self::assertSame('NOT_FOUND', $payload['error']['code'] ?? null);
}
public function testMenuDetailNonNumericReturns404WithoutQuery(): void
{
$db = new FakeCatalogueDatabase();
$response = $this->controller($db, '/api/menus/abc')->menu(['id' => 'abc']);
self::assertSame(404, $response->status());
self::assertSame([], $db->reads);
}
public function testMenuDetailSlotWithoutOptionsExposesEmptyList(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = [
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5',
'name' => 'Menu', 'description' => null,
'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => null, 'display_order' => '1',
];
// Slot remonte par le LEFT JOIN SANS option eligible (product_id NULL) : doit
// ressortir avec une liste VIDE, pas [0] ni absent (contrat 'slot vide -> []').
$db->menuSlotRows = [
['id' => '9', 'name' => 'Extra', 'slot_type' => 'extra', 'is_required' => '0', 'display_order' => '1', 'product_id' => null],
];
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
$slots = $payload['data']['slots'];
self::assertCount(1, $slots);
self::assertSame('extra', $slots[0]['slot_type']);
self::assertSame([], $slots[0]['option_product_ids']);
}
}