feat: CRUD categories P3 (rendu serveur, garde + CSRF + validation) #15

Merged
Corentin merged 1 commit from feat/p3-categories-crud into dev 2026-06-15 21:45:31 +02:00
11 changed files with 1094 additions and 3 deletions

View 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],
);
}
}

View file

@ -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;
}
}

View 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']);
}
}

View 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>

View 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>

View file

@ -18,6 +18,7 @@ declare(strict_types=1);
* @var list<string> $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 {
</nav>
<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 ?? '' ?>
</main>
</div>

View 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>

View file

@ -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) {

View 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);
}
}

View file

@ -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<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>}> */
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 [];

View 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'));
}
}