feat(api): P4 chunk 2 - read API catalogue borne (categories/produits/menus) (#60)
This commit is contained in:
parent
9bc0140b9a
commit
a35db88d2f
8 changed files with 973 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
220
src/app/Controllers/CatalogueController.php
Normal file
220
src/app/Controllers/CatalogueController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
233
tests/Integration/CatalogueReadDbTest.php
Normal file
233
tests/Integration/CatalogueReadDbTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
120
tests/Support/FakeCatalogueDatabase.php
Normal file
120
tests/Support/FakeCatalogueDatabase.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
291
tests/Unit/Catalogue/CatalogueControllerTest.php
Normal file
291
tests/Unit/Catalogue/CatalogueControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue