diff --git a/src/app/Catalogue/CategoryRepository.php b/src/app/Catalogue/CategoryRepository.php new file mode 100644 index 0000000..7859b79 --- /dev/null +++ b/src/app/Catalogue/CategoryRepository.php @@ -0,0 +1,105 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT id, name, slug, image_path, display_order, is_active ' + . 'FROM category ORDER BY display_order, name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, name, slug, image_path, display_order, is_active FROM category WHERE id = :id', + ['id' => $id], + ); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE name = :name AND id <> :id LIMIT 1', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + public function slugExists(string $slug, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE slug = :slug AND id <> :id LIMIT 1', + ['slug' => $slug, 'id' => $exceptId], + ) !== null; + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO category (name, slug, image_path, display_order, is_active) ' + . 'VALUES (:name, :slug, :image, :ord, :active)', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE category SET name = :name, slug = :slug, image_path = :image, display_order = :ord WHERE id = :id', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): void + { + $this->db->execute( + 'UPDATE category SET is_active = :active WHERE id = :id', + ['active' => $active ? 1 : 0, 'id' => $id], + ); + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index 130c0d2..54ca114 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -67,6 +67,7 @@ abstract class AdminController extends AuthenticatedController 'permissions' => $this->authorizer()->permissionsFor($roleId), 'csrfToken' => Csrf::token($this->sessionManager()), 'activeNav' => '', + 'flash' => $this->takeFlash(), ]; return $this->view($name, $data + $context, $status); @@ -76,4 +77,25 @@ abstract class AdminController extends AuthenticatedController { return new UserDirectory($this->database); } + + /** + * Message de confirmation a afficher apres une redirection (pose avant le 302, + * consomme au rendu suivant). Stocke en session pour survivre a la redirection. + */ + protected function setFlash(string $message): void + { + $this->sessionManager()->set('_flash', $message); + } + + private function takeFlash(): ?string + { + $flash = $this->sessionManager()->get('_flash'); + if ($flash === null) { + return null; + } + + $this->sessionManager()->set('_flash', null); + + return is_string($flash) ? $flash : null; + } } diff --git a/src/app/Controllers/CategoryController.php b/src/app/Controllers/CategoryController.php new file mode 100644 index 0000000..85d64da --- /dev/null +++ b/src/app/Controllers/CategoryController.php @@ -0,0 +1,279 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/categories/index', [ + 'title' => 'Categories - Wakdo Admin', + 'activeNav' => 'categories', + 'categories' => $this->categoryRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $repo = $this->categoryRepository(); + [$data, $errors] = $this->validate($form, $repo, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + try { + $repo->create($data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Categorie creee.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $category = $this->categoryRepository()->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $category, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + if ($repo->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $repo, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $repo->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Categorie mise a jour.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + $category = $repo->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($category['is_active'] ?? 0) !== 1; + $repo->setActive($id, $newActive); + $this->setFlash($newActive ? 'Categorie affichee.' : 'Categorie masquee.'); + + return $this->redirect('/admin/categories'); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->database); + } + + /** + * Validation serveur (RG-T18) + unicite. Renvoie [donnees normalisees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int}, 1: array} + */ + private function validate(array $form, CategoryRepository $repo, int $exceptId): array + { + $name = trim($form['name'] ?? ''); + $slug = trim($form['slug'] ?? ''); + $image = trim($form['image_path'] ?? ''); + $orderRaw = trim($form['display_order'] ?? '0'); + + $errors = []; + + if ($name === '' || mb_strlen($name) > 60) { + $errors['name'] = 'Le libelle est requis (60 caracteres max).'; + } elseif ($repo->nameExists($name, $exceptId)) { + $errors['name'] = 'Ce libelle existe deja.'; + } + + if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) { + $errors['slug'] = 'Slug requis : minuscules, chiffres et tirets (60 max).'; + } elseif ($repo->slugExists($slug, $exceptId)) { + $errors['slug'] = 'Ce slug existe deja.'; + } + + if ($image !== '' && mb_strlen($image) > 255) { + $errors['image_path'] = 'Chemin image trop long (255 max).'; + } + + // Borne haute = SMALLINT UNSIGNED (0..65535) : refuse cote serveur (RG-T18) + // plutot que de laisser un debordement remonter en 500 depuis la base. + if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) { + $errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.'; + } + + $data = [ + 'name' => $name, + 'slug' => $slug, + 'image_path' => $image !== '' ? $image : null, + 'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0, + 'is_active' => 1, + ]; + + return [$data, $errors]; + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/categories/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvelle') . ' categorie - Wakdo Admin', + 'activeNav' => 'categories', + 'categoryId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'slug' => (string) ($values['slug'] ?? ''), + 'image_path' => (string) ($values['image_path'] ?? ''), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * Traduit une violation de contrainte d'unicite (SQLSTATE 23000) en + * re-affichage 422 du formulaire plutot qu'en 500. Couvre la fenetre de + * concurrence entre le controle nameExists/slugExists et l'ecriture. Tout + * autre code d'erreur est repropage (vrai incident interne). + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + // getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast + // couvre aussi un code entier (23000 = violation de contrainte d'integrite). + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou ce slug existe deja.'], 422); + } + + throw $exception; + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'categories'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Views/admin/categories/form.php b/src/app/Views/admin/categories/form.php new file mode 100644 index 0000000..09b938f --- /dev/null +++ b/src/app/Views/admin/categories/form.php @@ -0,0 +1,64 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($categoryId ?? 0); +$action = $id !== 0 ? '/admin/categories/' . $id : '/admin/categories'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/categories/index.php b/src/app/Views/admin/categories/index.php new file mode 100644 index 0000000..0131ddf --- /dev/null +++ b/src/app/Views/admin/categories/index.php @@ -0,0 +1,73 @@ +> $categories + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +/** @var array> $rows */ +$rows = isset($categories) && is_array($categories) ? $categories : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LibelleSlugOrdreStatut
Aucune categorie.
+ + Visible + + Masquee + + + Modifier +
+ + +
+
+
+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 8f2695c..ea84009 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -18,6 +18,7 @@ declare(strict_types=1); * @var list $permissions * @var string $csrfToken * @var string $activeNav + * @var string|null $flash */ $pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8'); @@ -129,6 +130,10 @@ $navClass = static function (string $code, string $current): string {
+ + +
+
diff --git a/src/app/Views/admin/not_found.php b/src/app/Views/admin/not_found.php new file mode 100644 index 0000000..5bf525d --- /dev/null +++ b/src/app/Views/admin/not_found.php @@ -0,0 +1,18 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 027f9f8..9140055 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\CategoryController; use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; @@ -65,6 +66,14 @@ try { // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. + $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); + $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); + $router->add('POST', '/admin/categories', [CategoryController::class, 'store']); + $router->add('GET', '/admin/categories/{id}/edit', [CategoryController::class, 'edit']); + $router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']); + $router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/CategoryRepositoryDbTest.php b/tests/Integration/CategoryRepositoryDbTest.php new file mode 100644 index 0000000..0ca914b --- /dev/null +++ b/tests/Integration/CategoryRepositoryDbTest.php @@ -0,0 +1,97 @@ +db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $suffix = bin2hex(random_bytes(4)); + $this->slug = 'it-cat-' . $suffix; + $this->name = 'IT Cat ' . $suffix; + } + + protected function tearDown(): void + { + if ($this->slug !== '') { + $this->db->execute('DELETE FROM category WHERE slug = :slug', ['slug' => $this->slug]); + } + } + + public function testCreateFindUpdateAndToggle(): void + { + $repo = new CategoryRepository($this->db); + + $repo->create([ + 'name' => $this->name, + 'slug' => $this->slug, + 'image_path' => null, + 'display_order' => 99, + 'is_active' => 1, + ]); + + $idRow = $this->db->fetch('SELECT id FROM category WHERE slug = :slug', ['slug' => $this->slug]); + $id = (int) ($idRow['id'] ?? 0); + self::assertGreaterThan(0, $id); + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame($this->name, $found['name']); + self::assertSame(1, (int) ($found['is_active'] ?? 0)); + + // Unicite : present sauf si on s'exclut soi-meme. + self::assertTrue($repo->nameExists($this->name)); + self::assertFalse($repo->nameExists($this->name, $id)); + self::assertTrue($repo->slugExists($this->slug)); + self::assertFalse($repo->slugExists($this->slug, $id)); + + $repo->update($id, [ + 'name' => $this->name . ' (maj)', + 'slug' => $this->slug, + 'image_path' => 'x.png', + 'display_order' => 100, + ]); + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame($this->name . ' (maj)', $updated['name']); + self::assertSame('x.png', $updated['image_path']); + + $repo->setActive($id, false); + $toggled = $repo->find($id); + self::assertNotNull($toggled); + self::assertSame(0, (int) ($toggled['is_active'] ?? 1)); + + // all() renvoie la categorie creee. + $slugs = array_map(static fn (array $r): string => (string) ($r['slug'] ?? ''), $repo->all()); + self::assertContains($this->slug, $slugs); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index a68841e..f2e012c 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Tests\Support; use App\Core\DatabaseInterface; -use RuntimeException; +use Throwable; /** * Double de test de DatabaseInterface : aucune connexion reelle. Les lectures @@ -97,8 +97,28 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $userDisplayRow = null; - /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ - public ?RuntimeException $failOnExecute = null; + /** + * Lignes renvoyees par CategoryRepository::all(). + * + * @var list> + */ + public array $categoriesRows = []; + + /** + * Ligne renvoyee par CategoryRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $categoryRow = null; + + /** Resultat de CategoryRepository::nameExists(). */ + public bool $categoryNameTaken = false; + + /** Resultat de CategoryRepository::slugExists(). */ + public bool $categorySlugTaken = false; + + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ + public ?Throwable $failOnExecute = null; /** @var list}> */ public array $writes = []; @@ -146,6 +166,18 @@ final class FakeDatabase implements DatabaseInterface return $this->pinUserRow; } + if (str_contains($sql, 'FROM category WHERE id = :id')) { + return $this->categoryRow; + } + + if (str_contains($sql, 'FROM category WHERE name = :name')) { + return $this->categoryNameTaken ? ['id' => 1] : null; + } + + if (str_contains($sql, 'FROM category WHERE slug = :slug')) { + return $this->categorySlugTaken ? ['id' => 1] : null; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } @@ -161,6 +193,10 @@ final class FakeDatabase implements DatabaseInterface { $this->reads[] = ['sql' => $sql, 'params' => $params]; + if (str_contains($sql, 'FROM category ORDER BY')) { + return $this->categoriesRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/CategoryControllerTest.php b/tests/Unit/Admin/CategoryControllerTest.php new file mode 100644 index 0000000..6362291 --- /dev/null +++ b/tests/Unit/Admin/CategoryControllerTest.php @@ -0,0 +1,383 @@ +testSession; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->testSession, $this->fakeDb, $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->fakeDb); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->fakeDb); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->fakeDb); + } +} + +final class CategoryControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['category.manage']; + + return $db; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function controller(Request $request, FakeDatabase $db): TestCategoryController + { + return new TestCategoryController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + private function wroteContaining(FakeDatabase $db, string $needle): bool + { + return $db->wrote($needle); + } + + public function testGuardDeniesWithoutPermission(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + $response = $this->controller($this->get('/admin/categories'), $db)->index(); + + self::assertSame(403, $response->status()); + self::assertStringContainsString('Acces refuse', $response->body()); + } + + public function testIndexListsCategories(): void + { + $db = $this->permittedDb(); + $db->categoriesRows = [ + ['id' => 1, 'name' => 'Burgers', 'slug' => 'burgers', 'image_path' => null, 'display_order' => 2, 'is_active' => 1], + ['id' => 2, 'name' => 'Sauces', 'slug' => 'sauces', 'image_path' => null, 'display_order' => 9, 'is_active' => 0], + ]; + + $response = $this->controller($this->get('/admin/categories'), $db)->index(); + $body = $response->body(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Nouvelle categorie', $body); + self::assertStringContainsString('Burgers', $body); + self::assertStringContainsString('Visible', $body); // is_active = 1 + self::assertStringContainsString('Masquee', $body); // is_active = 0 + } + + public function testCreateShowsForm(): void + { + $response = $this->controller($this->get('/admin/categories/new'), $this->permittedDb())->create(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="slug"', $response->body()); + self::assertStringContainsString('action="/admin/categories"', $response->body()); + } + + public function testStoreValidCreatesAndRedirects(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/categories', $response->header('Location')); + self::assertTrue($this->wroteContaining($db, 'INSERT INTO category')); + self::assertSame('Categorie creee.', $this->session->get('_flash')); + } + + public function testStoreInvalidRerendersWithErrorsAndNoWrite(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => '', 'slug' => 'INVALID SLUG', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Le libelle est requis', $response->body()); + self::assertStringContainsString('Slug requis', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsDuplicateName(): void + { + $db = $this->permittedDb(); + $db->categoryNameTaken = true; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Ce libelle existe deja', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsOverRangeDisplayOrder(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '70000'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('entre 0 et 65535', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreTranslatesUniqueViolationTo422(): void + { + // Fenetre de concurrence : la base leve une violation 23000 a l'insertion ; + // le controleur doit re-afficher le formulaire (422), pas remonter un 500. + $db = $this->permittedDb(); + $db->failOnExecute = new \PDOException('duplicate', 23000); + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('existe deja', $response->body()); + } + + public function testStoreRejectsDuplicateSlug(): void + { + $db = $this->permittedDb(); + $db->categorySlugTaken = true; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('Ce slug existe deja', $response->body()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $request = $this->post( + ['_csrf' => 'wrong', 'name' => 'Desserts', 'slug' => 'desserts', 'display_order' => '7'], + '/admin/categories', + ); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(403, $response->status()); + self::assertFalse($this->wroteContaining($db, 'INSERT INTO category')); + } + + public function testEditNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + + $response = $this->controller($this->get('/admin/categories/999/edit'), $db)->edit(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + } + + public function testUpdateValidRedirects(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 1]; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Wraps & Co', 'slug' => 'wraps', 'display_order' => '3'], + '/admin/categories/5', + ); + + $response = $this->controller($request, $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($this->wroteContaining($db, 'UPDATE category SET name')); + } + + public function testToggleFlipsActiveAndRedirects(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 1]; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/5/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($this->wroteContaining($db, 'UPDATE category SET is_active')); + // Etait visible (1) -> on masque (0). + $write = null; + foreach ($db->writes as $w) { + if (str_contains($w['sql'], 'UPDATE category SET is_active')) { + $write = $w; + } + } + self::assertNotNull($write); + self::assertSame(0, $write['params']['active'] ?? null); + self::assertSame('Categorie masquee.', $this->session->get('_flash')); + } + + public function testToggleFromMaskedMakesVisible(): void + { + $db = $this->permittedDb(); + $db->categoryRow = ['id' => 5, 'name' => 'Wraps', 'slug' => 'wraps', 'image_path' => null, 'display_order' => 3, 'is_active' => 0]; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/5/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $write = null; + foreach ($db->writes as $w) { + if (str_contains($w['sql'], 'UPDATE category SET is_active')) { + $write = $w; + } + } + self::assertNotNull($write); + self::assertSame(1, $write['params']['active'] ?? null); + self::assertSame('Categorie affichee.', $this->session->get('_flash')); + } + + public function testUpdateNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + $request = $this->post( + ['_csrf' => $this->csrf, 'name' => 'Wraps', 'slug' => 'wraps', 'display_order' => '3'], + '/admin/categories/999', + ); + + $response = $this->controller($request, $db)->update(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + self::assertFalse($this->wroteContaining($db, 'UPDATE category SET name')); + } + + public function testToggleNotFoundReturns404(): void + { + $db = $this->permittedDb(); + $db->categoryRow = null; + $request = $this->post(['_csrf' => $this->csrf], '/admin/categories/999/toggle'); + + $response = $this->controller($request, $db)->toggle(['id' => '999']); + + self::assertSame(404, $response->status()); + self::assertStringContainsString('Introuvable', $response->body()); + self::assertFalse($this->wroteContaining($db, 'UPDATE category SET is_active')); + } +}