feat(admin): CRUD categories (P3, premier CRUD rendu serveur)
Some checks failed
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 17s
CI / static-tests (push) Successful in 30s
CI / secret-scan (pull_request) Successful in 7s
CI / php-lint (pull_request) Successful in 18s
CI / static-tests (pull_request) Successful in 32s
CI / auto-merge (pull_request) Failing after 4s
CI / auto-merge (push) Has been skipped
Some checks failed
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 17s
CI / static-tests (push) Successful in 30s
CI / secret-scan (pull_request) Successful in 7s
CI / php-lint (pull_request) Successful in 18s
CI / static-tests (pull_request) Successful in 32s
CI / auto-merge (pull_request) Failing after 4s
CI / auto-merge (push) Has been skipped
CategoryController (index/create/store/edit/update/toggle) sur AdminController : chaque action
gardee par category.manage (RG-T03), mutations validees CSRF (RG-T01) + serveur (RG-T18 : libelle/slug
requis, format, bornes, unicite ; ordre 0..65535), allowlist de colonnes (RG-T16). Pas de suppression
dure (FK RESTRICT) : bascule is_active. Violation de contrainte d'unicite (concurrence) traduite en 422,
pas en 500. Messages flash apres redirection. CategoryRepository : couche d'acces introduite pour les
entites CRUD. Vues admin/categories/{index,form} + not_found, sorties echappees. 144 tests (unit +
integration DB), PHPStan L6. Etablit le pattern reutilise par produits/menus/users.
This commit is contained in:
parent
2bc22ab5c8
commit
fe2547b77f
11 changed files with 1094 additions and 3 deletions
105
src/app/Catalogue/CategoryRepository.php
Normal file
105
src/app/Catalogue/CategoryRepository.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Catalogue;
|
||||||
|
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acces aux donnees de la table category (sous-domaine Catalogue). Premier
|
||||||
|
* repository du CRUD admin (P3) : centralise les requetes preparees d'une entite,
|
||||||
|
* reutilisees par les 6 actions du controleur et la validation d'unicite. Depend
|
||||||
|
* de DatabaseInterface pour rester testable avec un double.
|
||||||
|
*
|
||||||
|
* Pas de suppression dure : une categorie reference par des produits/menus
|
||||||
|
* (FK ON DELETE RESTRICT) ne se supprime pas ; la permission category.manage
|
||||||
|
* couvre create/update/deactivate (cf. seed). On bascule is_active a la place.
|
||||||
|
*/
|
||||||
|
final class CategoryRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DatabaseInterface $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ abstract class AdminController extends AuthenticatedController
|
||||||
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
||||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
'activeNav' => '',
|
'activeNav' => '',
|
||||||
|
'flash' => $this->takeFlash(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $this->view($name, $data + $context, $status);
|
return $this->view($name, $data + $context, $status);
|
||||||
|
|
@ -76,4 +77,25 @@ abstract class AdminController extends AuthenticatedController
|
||||||
{
|
{
|
||||||
return new UserDirectory($this->database);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
src/app/Controllers/CategoryController.php
Normal file
279
src/app/Controllers/CategoryController.php
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use PDOException;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\GuardResult;
|
||||||
|
use App\Catalogue\CategoryRepository;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD des categories du catalogue (P3). Premier CRUD admin, etablit le pattern :
|
||||||
|
* chaque action est gardee par guard('category.manage') (RG-T03), les ecritures
|
||||||
|
* valident le jeton CSRF (RG-T01) et les entrees cote serveur (RG-T18), puis
|
||||||
|
* redirigent avec un message flash. Pas de suppression dure (FK RESTRICT) : on
|
||||||
|
* bascule is_active (la permission couvre create/update/deactivate).
|
||||||
|
*
|
||||||
|
* Non `final` : les tests sous-classent pour injecter des doubles.
|
||||||
|
*/
|
||||||
|
class CategoryController extends AdminController
|
||||||
|
{
|
||||||
|
private const PERMISSION = 'category.manage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $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<string, string> $form
|
||||||
|
* @return array{0: array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int}, 1: array<string, string>}
|
||||||
|
*/
|
||||||
|
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<string, mixed> $values
|
||||||
|
* @param array<string, string> $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<string, mixed> $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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/Views/admin/categories/form.php
Normal file
64
src/app/Views/admin/categories/form.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulaire de creation/edition d'une categorie, injecte dans admin/layout.php.
|
||||||
|
* Reaffiche les valeurs soumises et les erreurs de validation (RG-T18). CSRF cache.
|
||||||
|
*
|
||||||
|
* @var int $categoryId 0 = creation, sinon edition
|
||||||
|
* @var array<string, mixed> $values
|
||||||
|
* @var array<string, string> $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<string, mixed> $vals */
|
||||||
|
$vals = isset($values) && is_array($values) ? $values : [];
|
||||||
|
/** @var array<string, string> $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] : '';
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title"><?= $id !== 0 ? 'Modifier la categorie' : 'Nouvelle categorie' ?></h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="<?= htmlspecialchars($action, ENT_QUOTES, 'UTF-8') ?>" class="form-card">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="name">Libelle</label>
|
||||||
|
<input class="form-input" type="text" id="name" name="name" maxlength="60" value="<?= $val('name') ?>" required>
|
||||||
|
<?php if ($err('name') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('name'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="slug">Slug</label>
|
||||||
|
<input class="form-input" type="text" id="slug" name="slug" maxlength="60" value="<?= $val('slug') ?>" required>
|
||||||
|
<?php if ($err('slug') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('slug'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="display_order">Ordre d'affichage</label>
|
||||||
|
<input class="form-input" type="number" id="display_order" name="display_order" min="0" value="<?= $val('display_order') ?>">
|
||||||
|
<?php if ($err('display_order') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('display_order'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="image_path">Chemin de l'image (optionnel)</label>
|
||||||
|
<input class="form-input" type="text" id="image_path" name="image_path" maxlength="255" value="<?= $val('image_path') ?>">
|
||||||
|
<?php if ($err('image_path') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('image_path'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
<a class="btn btn-secondary" href="/admin/categories">Annuler</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
73
src/app/Views/admin/categories/index.php
Normal file
73
src/app/Views/admin/categories/index.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des categories (CRUD admin), injectee dans admin/layout.php. Bascule de
|
||||||
|
* visibilite via formulaire POST + CSRF (pas de GET mutant). Tout texte echappe.
|
||||||
|
*
|
||||||
|
* @var array<int, array<string, mixed>> $categories
|
||||||
|
* @var string $csrfToken
|
||||||
|
*/
|
||||||
|
|
||||||
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
/** @var array<int, array<string, mixed>> $rows */
|
||||||
|
$rows = isset($categories) && is_array($categories) ? $categories : [];
|
||||||
|
|
||||||
|
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Categories</h1>
|
||||||
|
<p class="page-subtitle">Gestion des categories du catalogue</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="btn btn-primary" href="/admin/categories/new">Nouvelle categorie</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Libelle</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Ordre</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th style="width:160px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ($rows === []): ?>
|
||||||
|
<tr><td colspan="5" class="muted">Aucune categorie.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<?php
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
$active = (int) ($row['is_active'] ?? 0) === 1;
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
|
||||||
|
<td class="muted"><?= $esc($row['slug'] ?? '') ?></td>
|
||||||
|
<td class="muted"><?= $esc($row['display_order'] ?? 0) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($active): ?>
|
||||||
|
<span class="pill pill-success">Visible</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="pill pill-neutral">Masquee</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a class="btn btn-secondary" href="/admin/categories/<?= $id ?>/edit">Modifier</a>
|
||||||
|
<form method="post" action="/admin/categories/<?= $id ?>/toggle" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
<button class="btn btn-secondary" type="submit"><?= $active ? 'Masquer' : 'Afficher' ?></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -18,6 +18,7 @@ declare(strict_types=1);
|
||||||
* @var list<string> $permissions
|
* @var list<string> $permissions
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
* @var string $activeNav
|
* @var string $activeNav
|
||||||
|
* @var string|null $flash
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8');
|
$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8');
|
||||||
|
|
@ -129,6 +130,10 @@ $navClass = static function (string $code, string $current): string {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<?php $flashMessage = isset($flash) && is_string($flash) ? $flash : null; ?>
|
||||||
|
<?php if ($flashMessage !== null && $flashMessage !== ''): ?>
|
||||||
|
<div class="flash" role="status"><?= htmlspecialchars($flashMessage, ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
<?= $content ?? '' ?>
|
<?= $content ?? '' ?>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
18
src/app/Views/admin/not_found.php
Normal file
18
src/app/Views/admin/not_found.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment 404 generique du back-office, injecte dans admin/layout.php.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Introuvable</h1>
|
||||||
|
<p class="page-subtitle">La ressource demandee n'existe pas ou plus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p><a href="/admin/dashboard">Retour au tableau de bord</a></p>
|
||||||
|
</section>
|
||||||
|
|
@ -12,6 +12,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use App\Auth\SessionManager;
|
use App\Auth\SessionManager;
|
||||||
use App\Controllers\AuthController;
|
use App\Controllers\AuthController;
|
||||||
|
use App\Controllers\CategoryController;
|
||||||
use App\Controllers\DashboardController;
|
use App\Controllers\DashboardController;
|
||||||
use App\Controllers\HealthController;
|
use App\Controllers\HealthController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
|
|
@ -65,6 +66,14 @@ try {
|
||||||
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
||||||
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
|
$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 = $router->dispatch(Request::fromGlobals());
|
||||||
$response->send();
|
$response->send();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|
|
||||||
97
tests/Integration/CategoryRepositoryDbTest.php
Normal file
97
tests/Integration/CategoryRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Throwable;
|
||||||
|
use App\Catalogue\CategoryRepository;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD reel de CategoryRepository contre une vraie MariaDB (schema migre).
|
||||||
|
* Auto-skip si WAKDO_DB_TESTS != 1. Utilise un slug/libelle uniques (it-cat-*)
|
||||||
|
* pour ne pas heurter les 9 categories seedees ; nettoyage en tearDown.
|
||||||
|
*/
|
||||||
|
final class CategoryRepositoryDbTest extends TestCase
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
private string $slug = '';
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
namespace App\Tests\Support;
|
namespace App\Tests\Support;
|
||||||
|
|
||||||
use App\Core\DatabaseInterface;
|
use App\Core\DatabaseInterface;
|
||||||
use RuntimeException;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Double de test de DatabaseInterface : aucune connexion reelle. Les lectures
|
* Double de test de DatabaseInterface : aucune connexion reelle. Les lectures
|
||||||
|
|
@ -97,8 +97,28 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
*/
|
*/
|
||||||
public ?array $userDisplayRow = null;
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $categoriesRows = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne renvoyee par CategoryRepository::find() ; null = introuvable.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|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<array{sql: string, params: array<string|int, mixed>}> */
|
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
||||||
public array $writes = [];
|
public array $writes = [];
|
||||||
|
|
@ -146,6 +166,18 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
return $this->pinUserRow;
|
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')) {
|
if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) {
|
||||||
return ['lockout_until' => $this->ipLockoutUntil];
|
return ['lockout_until' => $this->ipLockoutUntil];
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +193,10 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
{
|
{
|
||||||
$this->reads[] = ['sql' => $sql, 'params' => $params];
|
$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 (str_contains($sql, 'SELECT p.code FROM role_permission')) {
|
||||||
if (!$this->roleActive) {
|
if (!$this->roleActive) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
383
tests/Unit/Admin/CategoryControllerTest.php
Normal file
383
tests/Unit/Admin/CategoryControllerTest.php
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\Authorizer;
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Auth\SessionGuard;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Auth\UserDirectory;
|
||||||
|
use App\Catalogue\CategoryRepository;
|
||||||
|
use App\Controllers\CategoryController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sous-classe de test : injecte session test + FakeDatabase dans la garde,
|
||||||
|
* l'autorisation, l'annuaire et le repository, sans base reelle.
|
||||||
|
*/
|
||||||
|
final class TestCategoryController extends CategoryController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Request $request,
|
||||||
|
Config $config,
|
||||||
|
Database $database,
|
||||||
|
private readonly SessionManager $testSession,
|
||||||
|
private readonly FakeDatabase $fakeDb,
|
||||||
|
) {
|
||||||
|
parent::__construct($request, $config, $database);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sessionManager(): SessionManager
|
||||||
|
{
|
||||||
|
return $this->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<string> */
|
||||||
|
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<string, string> $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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue