From a35db88d2ffdd75b79c55f1b405ac3b78e6e0749 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 16:10:36 +0200 Subject: [PATCH] feat(api): P4 chunk 2 - read API catalogue borne (categories/produits/menus) (#60) --- src/app/Catalogue/CategoryRepository.php | 15 + src/app/Catalogue/MenuRepository.php | 40 +++ src/app/Catalogue/ProductRepository.php | 41 +++ src/app/Controllers/CatalogueController.php | 220 +++++++++++++ src/public/admin/index.php | 13 + tests/Integration/CatalogueReadDbTest.php | 233 ++++++++++++++ tests/Support/FakeCatalogueDatabase.php | 120 ++++++++ .../Catalogue/CatalogueControllerTest.php | 291 ++++++++++++++++++ 8 files changed, 973 insertions(+) create mode 100644 src/app/Controllers/CatalogueController.php create mode 100644 tests/Integration/CatalogueReadDbTest.php create mode 100644 tests/Support/FakeCatalogueDatabase.php create mode 100644 tests/Unit/Catalogue/CatalogueControllerTest.php diff --git a/src/app/Catalogue/CategoryRepository.php b/src/app/Catalogue/CategoryRepository.php index 7859b79..c76458f 100644 --- a/src/app/Catalogue/CategoryRepository.php +++ b/src/app/Catalogue/CategoryRepository.php @@ -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> + */ + 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( diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php index 0ca9854..18bf773 100644 --- a/src/app/Catalogue/MenuRepository.php +++ b/src/app/Catalogue/MenuRepository.php @@ -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> + */ + 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|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. diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 3d1d371..0991400 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -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> + */ + 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|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; diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php new file mode 100644 index 0000000..c7b2acf --- /dev/null +++ b/src/app/Controllers/CatalogueController.php @@ -0,0 +1,220 @@ + $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 $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 $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 $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 $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 $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 $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 $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}> $slots + * @return list}> + */ + 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; + } +} diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 9b0ba13..102d9e0 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -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 diff --git a/tests/Integration/CatalogueReadDbTest.php b/tests/Integration/CatalogueReadDbTest.php new file mode 100644 index 0000000..a37f64f --- /dev/null +++ b/tests/Integration/CatalogueReadDbTest.php @@ -0,0 +1,233 @@ +* / IT Prod *), 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> $rows + * @return array|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, + ]; + } +} diff --git a/tests/Support/FakeCatalogueDatabase.php b/tests/Support/FakeCatalogueDatabase.php new file mode 100644 index 0000000..8ec5493 --- /dev/null +++ b/tests/Support/FakeCatalogueDatabase.php @@ -0,0 +1,120 @@ + 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> + */ + public array $categoriesRows = []; + + /** + * Lignes renvoyees par ProductRepository::availableForCatalogue(). + * + * @var list> + */ + public array $productsRows = []; + + /** + * Ligne renvoyee par ProductRepository::findForCatalogue() ; null = absent / + * indisponible / categorie inactive. + * + * @var array|null + */ + public ?array $productRow = null; + + /** + * Lignes renvoyees par MenuRepository::availableForCatalogue(). + * + * @var list> + */ + public array $menusRows = []; + + /** + * Ligne renvoyee par MenuRepository::findForCatalogue() ; null = absent / + * indisponible / categorie inactive. + * + * @var array|null + */ + public ?array $menuRow = null; + + /** + * Lignes brutes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions(). + * + * @var list> + */ + public array $menuSlotRows = []; + + /** + * Trace des lectures pour asserter le court-circuit du detail (id <= 0). + * + * @var list}> + */ + 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.'); + } +} diff --git a/tests/Unit/Catalogue/CatalogueControllerTest.php b/tests/Unit/Catalogue/CatalogueControllerTest.php new file mode 100644 index 0000000..e456caf --- /dev/null +++ b/tests/Unit/Catalogue/CatalogueControllerTest.php @@ -0,0 +1,291 @@ + 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 + */ + 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']); + } +}