From 7a0702ff6e3129c549d03cb3c9de20893e4f40ab Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 16:46:17 +0200 Subject: [PATCH] feat(borne): cablage de la borne sur l'API (CORS + data.js) (#61) --- src/app/Core/Cors.php | 99 ++++++++++++ src/public/admin/index.php | 28 +++- src/public/borne/assets/js/data.js | 129 ++++++++++++---- src/public/borne/assets/js/page-product.js | 2 +- tests/Unit/Core/CorsTest.php | 126 ++++++++++++++++ tests/js/data.test.js | 167 +++++++++++++++++++++ 6 files changed, 515 insertions(+), 36 deletions(-) create mode 100644 src/app/Core/Cors.php create mode 100644 tests/Unit/Core/CorsTest.php create mode 100644 tests/js/data.test.js diff --git a/src/app/Core/Cors.php b/src/app/Core/Cors.php new file mode 100644 index 0000000..d6c0cf0 --- /dev/null +++ b/src/app/Core/Cors.php @@ -0,0 +1,99 @@ + le navigateur bloque. + * + * Decouple de Config (recoit l'origine en chaine) -> testable sans environnement ; + * le front controller lit CORS_ALLOWED_ORIGIN et l'injecte. + */ +final class Cors +{ + private const ALLOW_METHODS = 'GET, POST, OPTIONS'; + private const ALLOW_HEADERS = 'Content-Type'; + private const MAX_AGE = '600'; + + public function __construct(private readonly string $allowedOrigin) + { + } + + /** + * Repond a une requete preliminaire (preflight) : OPTIONS sur /api/ depuis + * l'origine autorisee -> 204 avec les en-tetes CORS, court-circuitant le routeur + * (qui n'a pas de route OPTIONS). Renvoie null si ce n'est pas un preflight a + * traiter ici : le flux normal de dispatch continue. + */ + public function preflightResponse(Request $request): ?Response + { + if ($request->method() !== 'OPTIONS' || !$this->isAllowed($request)) { + return null; + } + + $response = (new Response())->setStatus(204); + $this->putHeaders($response, true); + + return $response; + } + + /** + * Pose les en-tetes CORS sur une reponse effective (GET/POST), y compris une + * reponse d'erreur (le navigateur a besoin de l'en-tete pour lire le corps d'une + * 4xx), si la requete vient de l'origine autorisee vers /api/. No-op sinon. + */ + public function applyTo(Request $request, Response $response): void + { + if (!$this->isAllowed($request)) { + return; + } + + $this->putHeaders($response, false); + } + + /** + * Origine exacte configuree ET requete /api/ ET Origin de la requete identique. + * Comparaison stricte par egalite (pas de prefixe, pas de joker). + */ + private function isAllowed(Request $request): bool + { + if ($this->allowedOrigin === '') { + return false; + } + + if (!str_starts_with($request->path(), '/api/')) { + return false; + } + + return $request->header('origin') === $this->allowedOrigin; + } + + private function putHeaders(Response $response, bool $preflight): void + { + $response->setHeader('Access-Control-Allow-Origin', $this->allowedOrigin); + $response->setHeader('Vary', 'Origin'); + + if ($preflight) { + $response->setHeader('Access-Control-Allow-Methods', self::ALLOW_METHODS); + $response->setHeader('Access-Control-Allow-Headers', self::ALLOW_HEADERS); + $response->setHeader('Access-Control-Max-Age', self::MAX_AGE); + } + } +} diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 102d9e0..b3d3745 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -29,6 +29,7 @@ use App\Controllers\RoleController; use App\Controllers\UserController; use App\Core\Autoloader; use App\Core\Config; +use App\Core\Cors; use App\Core\Database; use App\Core\Request; use App\Core\Response; @@ -46,6 +47,13 @@ header('X-Robots-Tag: noindex, nofollow'); $config = new Config(); date_default_timezone_set($config->timezone()); +// Requete + middleware CORS construits AVANT le try : ils ne dependent que de la +// config et des globales, et doivent rester accessibles dans le catch pour decorer +// la reponse 500 d'une requete /api/ cross-origin (sans quoi le navigateur de la +// borne ne peut pas lire le corps de l'erreur). +$request = Request::fromGlobals(); +$cors = new Cors($config->get('CORS_ALLOWED_ORIGIN', '') ?? ''); + try { // Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(), // donc la home back-office reste servie meme base indisponible. @@ -178,8 +186,18 @@ try { $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); - $response = $router->dispatch(Request::fromGlobals()); - $response->send(); + // CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le + // routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse. + // Scope /api/ + origine exacte geres par le middleware (fail-closed). $request et + // $cors sont construits hors du try pour que le catch puisse decorer aussi le 500. + $preflight = $cors->preflightResponse($request); + if ($preflight !== null) { + $preflight->send(); + } else { + $response = $router->dispatch($request); + $cors->applyTo($request, $response); + $response->send(); + } } catch (Throwable $exception) { // En debug on remonte le message pour iterer ; en prod, reponse generique // pour ne rien divulguer de la pile interne (information disclosure). @@ -187,5 +205,9 @@ try { ? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]] : ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']]; - (new Response())->json($payload, 500)->send(); + // Decore aussi la 500 : une requete /api/ cross-origin (ex. BDD indisponible) + // doit rester lisible par le navigateur de la borne (RG enveloppe d'erreur). + $errorResponse = (new Response())->json($payload, 500); + $cors->applyTo($request, $errorResponse); + $errorResponse->send(); } diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 378d82c..b02b5d1 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -1,27 +1,25 @@ /* * data.js — Data loading layer for the Wakdo kiosk. * - * P5 reads static JSON copies in /data/ (same origin). - * In P4, swap the BASE_URL constants to point to REST API endpoints. - * The function signatures and return shapes remain unchanged so that - * page scripts need no modification when the data source changes. + * Source = REST API (P4). La borne (kiosk) consomme l'API catalogue en lecture + * (docs/api/conventions.md section 5.2) : /api/categories, /api/products, /api/menus. + * Les reponses sont enveloppees ({ data: [...], total }) et en forme CANONIQUE + * (snake_case : name, price_cents, image_path...). Cette couche est le point unique + * de rapprochement (section 8.3) : elle deballe l'enveloppe et traduit vers la forme + * historique attendue par le reste de la borne (nom, prix, image, type ; objet + * indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures + * publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge. * - * Category-to-slug mapping (mirrors data/categories.json id field): - * 1=menus 2=boissons 3=burgers 4=frites 5=encas - * 6=wraps 7=salades 8=desserts 9=sauces + * Les allergenes restent un repli statique (data/allergens.json) : leur bascule + * sur /api/allergens est un chunk ulterieur. */ -/* --- P4 swap point ------------------------------------------------------- - * TODO(P4): replace these two paths with API endpoints, e.g.: - * const CATEGORIES_URL = '/api/categories'; - * const PRODUCTS_URL = '/api/products'; - * The rest of this file is API-agnostic. - * ----------------------------------------------------------------------- */ -const CATEGORIES_URL = 'data/categories.json'; -const PRODUCTS_URL = 'data/produits.json'; -/* Liste fixe des 14 allergenes INCO (info generale, modale borne). TODO(P4): - * remplacer par '/api/allergens'. Le reste du fichier est API-agnostique. */ -const ALLERGENS_URL = 'data/allergens.json'; +const CATEGORIES_URL = '/api/categories'; +const PRODUCTS_URL = '/api/products'; +const MENUS_URL = '/api/menus'; +/* Liste fixe des 14 allergenes INCO (info generale, modale borne). Repli statique + * encore en place : bascule sur '/api/allergens' differee. */ +const ALLERGENS_URL = 'data/allergens.json'; /** @type {Array|null} — in-memory cache to avoid repeated fetches */ let _categoriesCache = null; @@ -33,31 +31,87 @@ let _productsCache = null; let _allergensCache = null; /** - * Fetches and caches the categories list. + * Recupere une collection enveloppee de l'API et renvoie le tableau `data`. + * @param {string} url + * @returns {Promise} + */ +async function fetchCollection(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load ${url}: HTTP ${res.status}`); + const body = await res.json(); + return Array.isArray(body?.data) ? body.data : []; +} + +/** + * Fetches and caches the categories list (forme borne : id, title, slug, image). * @returns {Promise} */ export async function loadCategories() { if (_categoriesCache) return _categoriesCache; - const res = await fetch(CATEGORIES_URL); - if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`); - _categoriesCache = await res.json(); + const rows = await fetchCollection(CATEGORIES_URL); + _categoriesCache = rows.map(c => ({ + id: c.id, + title: c.name, + slug: c.slug, + image: c.image_path, + })); return _categoriesCache; } /** - * Fetches and caches the full products object keyed by category slug. + * Fetches and caches the products object keyed by category slug. Les produits et + * les menus sont regroupes par slug de leur categorie (les menus tombent sous + * 'menus' via leur category_id) et ramenes a la forme borne. Le menu garde son + * prix NORMAL (le supplement Maxi est gere par le composeur cote borne). * @returns {Promise} */ export async function loadProducts() { if (_productsCache) return _productsCache; - const res = await fetch(PRODUCTS_URL); - if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`); - _productsCache = await res.json(); + + const [categories, products, menus] = await Promise.all([ + loadCategories(), + fetchCollection(PRODUCTS_URL), + fetchCollection(MENUS_URL), + ]); + + const slugByCategoryId = {}; + const bySlug = {}; + for (const cat of categories) { + slugByCategoryId[cat.id] = cat.slug; + bySlug[cat.slug] = []; + } + + for (const p of products) { + const slug = slugByCategoryId[p.category_id]; + if (slug === undefined) continue; + bySlug[slug].push({ + id: p.id, + nom: p.name, + prix: p.price_cents, + image: p.image_path, + type: 'produit', + }); + } + + for (const m of menus) { + const slug = slugByCategoryId[m.category_id]; + if (slug === undefined) continue; + bySlug[slug].push({ + id: m.id, + nom: m.name, + prix: m.price_normal_cents, + image: m.image_path, + type: 'menu', + }); + } + + _productsCache = bySlug; return _productsCache; } /** - * Fetches and caches the 14 INCO allergens (general info modal). + * Fetches and caches the 14 INCO allergens (general info modal). Repli statique : + * la reponse est un tableau nu (pas d'enveloppe), conserve tel quel. * @returns {Promise} */ export async function loadAllergens() { @@ -90,13 +144,24 @@ export async function getCategoryById(id) { } /** - * Finds a product by its numeric id, searching all category slates. - * Returns null if not found. + * Finds a product/menu by id. product et menu sont deux espaces d'id DISTINCTS + * (tables auto-increment separees) : un meme id peut designer a la fois un produit + * et un menu. categorySlug (le slug de la categorie d'ou vient l'appel) leve + * l'ambiguite -- dans une categorie donnee, l'id est unique. Sans categorySlug, on + * retombe sur un scan global (best-effort, potentiellement ambigu en cas de + * collision d'id). Renvoie null si introuvable. * @param {number} id + * @param {string|null} [categorySlug] * @returns {Promise} */ -export async function findProduct(id) { +export async function findProduct(id, categorySlug = null) { const data = await loadProducts(); + + if (categorySlug !== null && Array.isArray(data[categorySlug])) { + const found = data[categorySlug].find(p => p.id === id); + return found ? { ...found, categorie: categorySlug } : null; + } + for (const slug of Object.keys(data)) { const found = data[slug].find(p => p.id === id); if (found) return { ...found, categorie: slug }; @@ -106,8 +171,8 @@ export async function findProduct(id) { /** * Maps a category id integer to its slug string. - * Derived from data/categories.json — kept here as a convenience - * so page scripts can convert query-string ids without an extra fetch. + * Derived from the seed catalogue — kept here as a convenience so page scripts can + * convert query-string ids without an extra fetch. */ export const CATEGORY_ID_TO_SLUG = { 1: 'menus', diff --git a/src/public/borne/assets/js/page-product.js b/src/public/borne/assets/js/page-product.js index 11df207..1198892 100644 --- a/src/public/borne/assets/js/page-product.js +++ b/src/public/borne/assets/js/page-product.js @@ -40,7 +40,7 @@ async function renderProduct() { } try { - const product = await findProduct(productId); + const product = await findProduct(productId, categorySlug); if (!product) { showError('Ce produit n\'existe pas.'); return; diff --git a/tests/Unit/Core/CorsTest.php b/tests/Unit/Core/CorsTest.php new file mode 100644 index 0000000..08c38bf --- /dev/null +++ b/tests/Unit/Core/CorsTest.php @@ -0,0 +1,126 @@ + aucun en-tete. + */ +final class CorsTest extends TestCase +{ + private const ORIGIN = 'http://kiosk.localhost:8080'; + + private function request(string $method, string $path, ?string $origin): Request + { + $headers = $origin === null ? [] : ['origin' => $origin]; + + return new Request($method, $path, [], $headers, ''); + } + + private function cors(string $allowed = self::ORIGIN): Cors + { + return new Cors($allowed); + } + + public function testPreflightFromAllowedOriginReturns204WithCorsHeaders(): void + { + $response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', self::ORIGIN)); + + self::assertNotNull($response); + self::assertSame(204, $response->status()); + self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin')); + self::assertSame('Origin', $response->header('Vary')); + $methods = (string) $response->header('Access-Control-Allow-Methods'); + self::assertStringContainsString('GET', $methods); + self::assertStringContainsString('POST', $methods); + self::assertStringContainsString('OPTIONS', $methods); + self::assertSame('Content-Type', $response->header('Access-Control-Allow-Headers')); + self::assertSame('', $response->body()); + } + + public function testPreflightFromUnknownOriginIsNotHandled(): void + { + // Pas de court-circuit : le routeur gerera (405), sans en-tete CORS -> bloque navigateur. + $response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', 'http://evil.example')); + + self::assertNull($response); + } + + public function testPreflightWithoutOriginIsNotHandled(): void + { + $response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', null)); + + self::assertNull($response); + } + + public function testPreflightOutsideApiIsNotHandled(): void + { + $response = $this->cors()->preflightResponse($this->request('OPTIONS', '/login', self::ORIGIN)); + + self::assertNull($response); + } + + public function testApplyToAddsOriginHeaderForAllowedApiRequest(): void + { + $response = (new Response())->json(['data' => []], 200); + $this->cors()->applyTo($this->request('GET', '/api/products', self::ORIGIN), $response); + + self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin')); + self::assertSame('Origin', $response->header('Vary')); + // Une reponse effective ne porte pas les en-tetes de preflight. + self::assertNull($response->header('Access-Control-Allow-Methods')); + } + + public function testApplyToDecoratesErrorResponsesToo(): void + { + // Le navigateur a besoin de l'en-tete CORS meme sur une 404 pour lire le corps. + $response = (new Response())->json(['data' => null, 'error' => ['code' => 'NOT_FOUND']], 404); + $this->cors()->applyTo($this->request('GET', '/api/products/999', self::ORIGIN), $response); + + self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin')); + } + + public function testApplyToIgnoresUnknownOrigin(): void + { + $response = (new Response())->json(['data' => []], 200); + $this->cors()->applyTo($this->request('GET', '/api/products', 'http://evil.example'), $response); + + self::assertNull($response->header('Access-Control-Allow-Origin')); + } + + public function testApplyToIgnoresNonApiPath(): void + { + $response = (new Response())->html('', 200); + $this->cors()->applyTo($this->request('GET', '/login', self::ORIGIN), $response); + + self::assertNull($response->header('Access-Control-Allow-Origin')); + } + + public function testDisabledWhenNoAllowedOriginConfigured(): void + { + $cors = $this->cors(''); + + self::assertNull($cors->preflightResponse($this->request('OPTIONS', '/api/categories', self::ORIGIN))); + + $response = (new Response())->json(['data' => []], 200); + $cors->applyTo($this->request('GET', '/api/products', self::ORIGIN), $response); + self::assertNull($response->header('Access-Control-Allow-Origin')); + } + + public function testOriginMatchIsExactNotSubstring(): void + { + // Pas de joker ni de prefixe : une origine voisine ne doit jamais matcher. + $response = (new Response())->json(['data' => []], 200); + $this->cors()->applyTo($this->request('GET', '/api/products', self::ORIGIN . '.evil.com'), $response); + + self::assertNull($response->header('Access-Control-Allow-Origin')); + } +} diff --git a/tests/js/data.test.js b/tests/js/data.test.js new file mode 100644 index 0000000..1eb06f8 --- /dev/null +++ b/tests/js/data.test.js @@ -0,0 +1,167 @@ +/* + * Tests de la couche data.js du front borne (node:test, sans DOM). + * + * Couvre le swap P4 : data.js consomme l'API REST (/api/categories|products|menus), + * deballe l'enveloppe {data}, et traduit la forme canonique (snake_case, name, + * price_cents, image_path) vers la forme attendue par la borne (nom, prix, image, + * type, objet indexe par slug, menus sous la cle 'menus'). fetch est mocke ; chaque + * cas reimporte data.js avec une query unique pour repartir d'un cache vide. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +let _seq = 0; + +/** + * Installe un mock de fetch route par URL et reimporte data.js avec un cache neuf. + * @param {Record} routes reponses JSON par URL + * @param {string[]} [calls] collecteur des URLs appelees (optionnel) + */ +async function freshData(routes, calls) { + global.fetch = async (url) => { + if (calls) calls.push(url); + if (!(url in routes)) throw new Error(`fetch inattendu: ${url}`); + return { ok: true, status: 200, json: async () => routes[url] }; + }; + + return import(`../../src/public/borne/assets/js/data.js?case=${_seq++}`); +} + +function fixtures() { + return { + '/api/categories': { + data: [ + { id: 1, name: 'Menus', slug: 'menus', image_path: 'assets/images/categories/menus.png', display_order: 1 }, + { id: 3, name: 'Burgers', slug: 'burgers', image_path: 'assets/images/categories/burgers.png', display_order: 3 }, + ], + total: 2, + }, + '/api/products': { + data: [ + { id: 10, category_id: 3, name: 'Big Mac', description: 'Pain, steak, cheddar', price_cents: 600, image_path: 'assets/images/produits/burgers/bigmac.png', display_order: 4 }, + ], + total: 1, + }, + '/api/menus': { + data: [ + { id: 1, category_id: 1, burger_product_id: 10, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'assets/images/produits/burgers/bigmac.png', display_order: 1 }, + ], + total: 1, + }, + }; +} + +test('loadCategories appelle /api/categories, deballe {data} et mappe name->title, image_path->image', async () => { + const calls = []; + const { loadCategories } = await freshData(fixtures(), calls); + + const cats = await loadCategories(); + assert.ok(Array.isArray(cats)); + assert.equal(cats.length, 2); + assert.deepEqual(cats[0], { id: 1, title: 'Menus', slug: 'menus', image: 'assets/images/categories/menus.png' }); + assert.ok(calls.includes('/api/categories'), 'doit fetch /api/categories'); +}); + +test('loadProducts groupe les produits par slug a la forme borne (type produit)', async () => { + const { loadProducts } = await freshData(fixtures()); + + const data = await loadProducts(); + assert.deepEqual(data.burgers, [ + { id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit' }, + ]); +}); + +test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_normal_cents)', async () => { + const { loadProducts } = await freshData(fixtures()); + + const data = await loadProducts(); + assert.deepEqual(data.menus, [ + { id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' }, + ]); +}); + +test('loadProducts consomme bien les trois endpoints /api/*', async () => { + const calls = []; + const { loadProducts } = await freshData(fixtures(), calls); + + await loadProducts(); + for (const url of ['/api/categories', '/api/products', '/api/menus']) { + assert.ok(calls.includes(url), `doit fetch ${url}`); + } +}); + +test('getProductsByCategory derive de loadProducts (forme borne), [] si slug inconnu', async () => { + const { getProductsByCategory } = await freshData(fixtures()); + + const burgers = await getProductsByCategory('burgers'); + assert.equal(burgers.length, 1); + assert.equal(burgers[0].nom, 'Big Mac'); + assert.deepEqual(await getProductsByCategory('inexistant'), []); +}); + +test('findProduct trouve un produit et l enrichit de sa categorie (slug)', async () => { + const { findProduct } = await freshData(fixtures()); + + const product = await findProduct(10); + assert.equal(product.nom, 'Big Mac'); + assert.equal(product.prix, 600); + assert.equal(product.type, 'produit'); + assert.equal(product.categorie, 'burgers'); +}); + +test('findProduct trouve un menu (type menu, categorie menus, prix normal)', async () => { + const { findProduct } = await freshData(fixtures()); + + const menu = await findProduct(1); + assert.equal(menu.nom, 'Menu Big Mac'); + assert.equal(menu.type, 'menu'); + assert.equal(menu.categorie, 'menus'); + assert.equal(menu.prix, 800); +}); + +test('un statut HTTP non-ok fait rejeter le chargement', async () => { + global.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) }); + const { loadCategories } = await import(`../../src/public/borne/assets/js/data.js?case=err${_seq++}`); + + await assert.rejects(() => loadCategories()); +}); + +test('findProduct desambigue par categorie quand un produit et un menu partagent un id', async () => { + // product et menu sont deux tables auto-increment distinctes : l'id 4 designe a + // la fois le burger Big Mac (product) et le Menu Big Mac (menu). Sans categorie, + // un scan global renverrait le menu (scanne avant burgers) -> mauvais produit. + const colliding = { + '/api/categories': { + data: [ + { id: 1, name: 'Menus', slug: 'menus', image_path: 'm.png', display_order: 1 }, + { id: 3, name: 'Burgers', slug: 'burgers', image_path: 'b.png', display_order: 3 }, + ], + }, + '/api/products': { + data: [ + { id: 4, category_id: 3, name: 'Big Mac', description: null, price_cents: 600, image_path: 'bigmac.png', display_order: 4 }, + ], + }, + '/api/menus': { + data: [ + { id: 4, category_id: 1, burger_product_id: 4, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'bigmac.png', display_order: 1 }, + ], + }, + }; + const { findProduct } = await freshData(colliding); + + const burger = await findProduct(4, 'burgers'); + assert.equal(burger.type, 'produit', 'la categorie burgers doit donner le produit, pas le menu'); + assert.equal(burger.nom, 'Big Mac'); + assert.equal(burger.categorie, 'burgers'); + + const menu = await findProduct(4, 'menus'); + assert.equal(menu.type, 'menu'); + assert.equal(menu.nom, 'Menu Big Mac'); + assert.equal(menu.categorie, 'menus'); +}); + +test('findProduct renvoie null si l id est absent de la categorie ciblee', async () => { + const { findProduct } = await freshData(fixtures()); + assert.equal(await findProduct(999, 'burgers'), null); +});