first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserController;
use App\User\UserServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour UserController.
*
* Couvre les 5 actions publiques :
* - index() : rendu de la liste
* - showCreate() : rendu du formulaire
* - create() : mismatch, username dupliqué, email dupliqué, mot de passe faible, succès
* - updateRole() : introuvable, propre rôle, cible admin, rôle invalide, succès
* - delete() : introuvable, cible admin, soi-même, succès
*/
final class UserControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var UserServiceInterface&MockObject */
private UserServiceInterface $userService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private UserController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->userService = $this->createMock(UserServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->controller = new UserController(
$this->view,
$this->userService,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit rendre la vue avec la liste des utilisateurs.
*/
public function testIndexRendersWithUserList(): void
{
$this->userService->method('findAll')->willReturn([]);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/users/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/users'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── showCreate ───────────────────────────────────────────────────
/**
* showCreate() doit rendre le formulaire de création.
*/
public function testShowCreateRendersForm(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/users/form.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->showCreate($this->makeGet('/admin/users/create'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
*/
public function testCreateRedirectsWhenPasswordMismatch(): void
{
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Les mots de passe ne correspondent pas');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'pass1',
'password_confirm' => 'pass2',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() ne doit pas appeler userService si les mots de passe ne correspondent pas.
*/
public function testCreateDoesNotCallServiceOnMismatch(): void
{
$this->userService->expects($this->never())->method('createUser');
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'aaa',
'password_confirm' => 'bbb',
]);
$this->controller->create($req, $this->makeResponse());
}
/**
* create() doit rediriger avec une erreur si le nom d'utilisateur est déjà pris.
*/
public function testCreateRedirectsOnDuplicateUsername(): void
{
$this->userService->method('createUser')
->willThrowException(new DuplicateUsernameException('alice'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("nom d'utilisateur est déjà pris"));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si l'email est déjà utilisé.
*/
public function testCreateRedirectsOnDuplicateEmail(): void
{
$this->userService->method('createUser')
->willThrowException(new DuplicateEmailException('alice@example.com'));
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('e-mail est déjà utilisée'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit rediriger avec une erreur si le mot de passe est trop court.
*/
public function testCreateRedirectsOnWeakPassword(): void
{
$this->userService->method('createUser')
->willThrowException(new WeakPasswordException());
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('8 caractères'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'short',
'password_confirm' => 'short',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users/create');
}
/**
* create() doit flasher un succès et rediriger vers /admin/users en cas de succès.
*/
public function testCreateRedirectsToUsersListOnSuccess(): void
{
$this->userService->method('createUser')->willReturn($this->makeUser(99, 'alice', User::ROLE_USER));
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('alice'));
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
]);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/users');
}
/**
* create() doit forcer le rôle 'user' si un rôle admin est soumis dans le formulaire.
*/
public function testCreateForcesRoleUserWhenAdminRoleSubmitted(): void
{
$this->userService->expects($this->once())
->method('createUser')
->with('alice', 'alice@example.com', 'password123', User::ROLE_USER);
$this->flash->method('set');
$req = $this->makePost('/admin/users/create', [
'username' => 'alice',
'email' => 'alice@example.com',
'password' => 'password123',
'password_confirm' => 'password123',
'role' => User::ROLE_ADMIN, // rôle injecté par l'attaquant
]);
$this->controller->create($req, $this->makeResponse());
}
// ── updateRole ───────────────────────────────────────────────────
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testUpdateRoleRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/99', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'admin tente de changer son propre rôle.
*/
public function testUpdateRoleRedirectsWhenAdminTriesToChangeOwnRole(): void
{
$user = $this->makeUser(1, 'admin', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre rôle'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/1', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si l'utilisateur cible est déjà admin.
*/
public function testUpdateRoleRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains("administrateur ne peut pas être modifié"));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit rediriger avec une erreur si le rôle soumis est invalide.
*/
public function testUpdateRoleRedirectsOnInvalidRole(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Rôle invalide');
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => 'superuser']),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* updateRole() doit appeler userService et rediriger avec succès.
*/
public function testUpdateRoleRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('updateRole')->with(2, User::ROLE_EDITOR);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->updateRole(
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit rediriger avec une erreur si l'utilisateur est introuvable.
*/
public function testDeleteRedirectsWhenUserNotFound(): void
{
$this->userService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('user_error', 'Utilisateur introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si la cible est administrateur.
*/
public function testDeleteRedirectsWhenTargetIsAdmin(): void
{
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('administrateur ne peut pas être supprimé'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit rediriger avec une erreur si l'admin tente de supprimer son propre compte.
*/
public function testDeleteRedirectsWhenAdminTriesToDeleteOwnAccount(): void
{
$user = $this->makeUser(1, 'alice', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
$this->flash->expects($this->once())->method('set')
->with('user_error', $this->stringContains('propre compte'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/1'),
$this->makeResponse(),
['id' => '1'],
);
$this->assertRedirectTo($res, '/admin/users');
}
/**
* delete() doit appeler userService et rediriger avec succès.
*/
public function testDeleteRedirectsWithSuccessFlash(): void
{
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
$this->userService->method('findById')->willReturn($user);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->userService->expects($this->once())->method('delete')->with(2);
$this->flash->expects($this->once())->method('set')
->with('user_success', $this->stringContains('bob'));
$res = $this->controller->delete(
$this->makePost('/admin/users/delete/2'),
$this->makeResponse(),
['id' => '2'],
);
$this->assertRedirectTo($res, '/admin/users');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec les paramètres minimaux.
*/
private function makeUser(int $id, string $username, string $role): User
{
return new User($id, $username, "{$username}@example.com", password_hash('secret', PASSWORD_BCRYPT), $role);
}
}

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\User;
use App\User\UserRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class UserRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private UserRepository $repository;
/**
* Données représentant une ligne utilisateur en base de données.
*
* @var array<string, mixed>
*/
private array $rowAlice;
/**
* Initialise le mock PDO, le dépôt et les données de test avant chaque test.
*/
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new UserRepository($this->db);
$this->rowAlice = [
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
'role' => User::ROLE_USER,
'created_at' => '2024-01-01 00:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForWrite(): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() doit retourner un tableau vide si aucun utilisateur n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() doit retourner un tableau d'instances User hydratées.
*/
public function testFindAllReturnsUserInstances(): void
{
$stmt = $this->stmtForRead([$this->rowAlice]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(User::class, $result[0]);
$this->assertSame('alice', $result[0]->getUsername());
}
/**
* findAll() doit interroger la table 'users' avec un tri par created_at ASC.
*/
public function testFindAllQueriesWithAscendingOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('users'),
$this->stringContains('created_at ASC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() doit retourner null si aucun utilisateur ne correspond à cet identifiant.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() doit retourner une instance User hydratée si l'utilisateur existe.
*/
public function testFindByIdReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(User::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() doit exécuter avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 42]);
$this->repository->findById(42);
}
// ── findByUsername ─────────────────────────────────────────────
/**
* findByUsername() doit retourner null si le nom d'utilisateur est introuvable.
*/
public function testFindByUsernameReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByUsername('inconnu'));
}
/**
* findByUsername() doit retourner une instance User si le nom est trouvé.
*/
public function testFindByUsernameReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByUsername('alice');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice', $result->getUsername());
}
/**
* findByUsername() doit exécuter avec le bon nom d'utilisateur.
*/
public function testFindByUsernameQueriesWithCorrectName(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':username' => 'alice']);
$this->repository->findByUsername('alice');
}
// ── findByEmail ────────────────────────────────────────────────
/**
* findByEmail() doit retourner null si l'adresse e-mail est introuvable.
*/
public function testFindByEmailReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByEmail('inconnu@example.com'));
}
/**
* findByEmail() doit retourner une instance User si l'e-mail est trouvé.
*/
public function testFindByEmailReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByEmail('alice@example.com');
$this->assertInstanceOf(User::class, $result);
$this->assertSame('alice@example.com', $result->getEmail());
}
/**
* findByEmail() doit exécuter avec la bonne adresse e-mail.
*/
public function testFindByEmailQueriesWithCorrectEmail(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':email' => 'alice@example.com']);
$this->repository->findByEmail('alice@example.com');
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit préparer un INSERT sur la table 'users' avec les bonnes données.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($user): bool {
return $data[':username'] === $user->getUsername()
&& $data[':email'] === $user->getEmail()
&& $data[':password_hash'] === $user->getPasswordHash()
&& $data[':role'] === $user->getRole()
&& isset($data[':created_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($user);
}
/**
* create() doit retourner l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$user = User::fromArray($this->rowAlice);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('42');
$this->assertSame(42, $this->repository->create($user));
}
// ── updatePassword ─────────────────────────────────────────────
/**
* updatePassword() doit préparer un UPDATE avec le nouveau hash et le bon identifiant.
*/
public function testUpdatePasswordCallsUpdateWithCorrectHash(): void
{
$newHash = password_hash('nouveaumdp', PASSWORD_BCRYPT);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':password_hash' => $newHash, ':id' => 1]);
$this->repository->updatePassword(1, $newHash);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit préparer un UPDATE avec le bon rôle et le bon identifiant.
*/
public function testUpdateRoleCallsUpdateWithCorrectRole(): void
{
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('UPDATE users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':role' => User::ROLE_EDITOR, ':id' => 1]);
$this->repository->updateRole(1, User::ROLE_EDITOR);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit préparer un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM users'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 7]);
$this->repository->delete(7);
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use App\User\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserService.
*
* Vérifie la création de compte : normalisation, unicité du nom d'utilisateur
* et de l'email, validation de la complexité du mot de passe.
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
* isoler le service.
*/
final class UserServiceTest extends TestCase
{
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private UserService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->userRepository);
}
// ── createUser ─────────────────────────────────────────────────
/**
* createUser() doit créer et retourner un utilisateur avec les bonnes données.
*/
public function testCreateUserWithValidData(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->expects($this->once())->method('create');
$user = $this->service->createUser('Alice', 'alice@example.com', 'motdepasse1');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* createUser() doit normaliser le nom d'utilisateur et l'email en minuscules.
*/
public function testCreateUserNormalizesToLowercase(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse1');
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
}
/**
* createUser() doit lever DuplicateUsernameException si le nom est déjà pris.
*/
public function testCreateUserDuplicateUsernameThrowsDuplicateUsernameException(): void
{
$existingUser = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn($existingUser);
$this->expectException(DuplicateUsernameException::class);
$this->service->createUser('alice', 'autre@example.com', 'motdepasse1');
}
/**
* createUser() doit lever DuplicateEmailException si l'email est déjà utilisé.
*/
public function testCreateUserDuplicateEmailThrowsDuplicateEmailException(): void
{
$existingUser = $this->makeUser('bob', 'alice@example.com');
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn($existingUser);
$this->expectException(DuplicateEmailException::class);
$this->service->createUser('newuser', 'alice@example.com', 'motdepasse1');
}
/**
* createUser() doit lever WeakPasswordException si le mot de passe est trop court.
*/
public function testCreateUserTooShortPasswordThrowsWeakPasswordException(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->expectException(WeakPasswordException::class);
$this->service->createUser('alice', 'alice@example.com', '1234567');
}
/**
* createUser() avec exactement 8 caractères de mot de passe doit réussir.
*/
public function testCreateUserMinimumPasswordLength(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', '12345678');
$this->assertInstanceOf(User::class, $user);
}
/**
* createUser() doit stocker un hash bcrypt, jamais le mot de passe en clair.
*/
public function testCreateUserPasswordIsHashed(): void
{
$plainPassword = 'motdepasse1';
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', $plainPassword);
$this->assertNotSame($plainPassword, $user->getPasswordHash());
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
}
/**
* createUser() doit attribuer le rôle passé en paramètre.
*/
public function testCreateUserWithEditorRole(): void
{
$this->userRepository->method('findByUsername')->willReturn(null);
$this->userRepository->method('findByEmail')->willReturn(null);
$this->userRepository->method('create');
$user = $this->service->createUser('alice', 'alice@example.com', 'motdepasse1', User::ROLE_EDITOR);
$this->assertSame(User::ROLE_EDITOR, $user->getRole());
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() délègue au repository et retourne la liste.
*/
public function testFindAllDelegatesToRepository(): void
{
$users = [$this->makeUser('alice', 'alice@example.com')];
$this->userRepository->method('findAll')->willReturn($users);
$this->assertSame($users, $this->service->findAll());
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si l'utilisateur est introuvable.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->userRepository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(99));
}
/**
* findById() retourne l'utilisateur trouvé.
*/
public function testFindByIdReturnsUser(): void
{
$user = $this->makeUser('alice', 'alice@example.com');
$this->userRepository->method('findById')->with(1)->willReturn($user);
$this->assertSame($user, $this->service->findById(1));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() délègue la suppression au repository.
*/
public function testDeleteDelegatesToRepository(): void
{
$this->userRepository->method('findById')->with(5)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('delete')->with(5);
$this->service->delete(5);
}
// ── updateRole ─────────────────────────────────────────────────
/**
* updateRole() doit déléguer au repository avec le rôle validé.
*/
public function testUpdateRoleDelegatesToRepository(): void
{
$this->userRepository->method('findById')->with(3)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())
->method('updateRole')
->with(3, User::ROLE_EDITOR);
$this->service->updateRole(3, User::ROLE_EDITOR);
}
/**
* updateRole() doit lever InvalidArgumentException pour un rôle inconnu.
*/
public function testUpdateRoleThrowsOnInvalidRole(): void
{
$this->userRepository->expects($this->never())->method('updateRole');
$this->expectException(InvalidRoleException::class);
$this->service->updateRole(1, 'superadmin');
}
/**
* updateRole() accepte les trois rôles valides sans lever d'exception.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
public function testUpdateRoleAcceptsAllValidRoles(string $role): void
{
$this->userRepository->method('findById')->with(1)->willReturn($this->makeUser('alice', 'alice@example.com'));
$this->userRepository->expects($this->once())->method('updateRole')->with(1, $role);
$this->service->updateRole(1, $role);
}
/**
* @return array<string, array{string}>
*/
public static function validRolesProvider(): array
{
return [
'user' => [User::ROLE_USER],
'editor' => [User::ROLE_EDITOR],
'admin' => [User::ROLE_ADMIN],
];
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
*/
private function makeUser(string $username, string $email): User
{
return new User(1, $username, $email, password_hash('motdepasse1', PASSWORD_BCRYPT));
}
}

232
tests/User/UserTest.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Tests\User;
use App\User\User;
use DateTime;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour le modèle User.
*
* Vérifie la construction, la validation, les accesseurs
* et l'hydratation depuis un tableau de base de données.
*/
final class UserTest extends TestCase
{
// ── Construction valide ────────────────────────────────────────
/**
* Un utilisateur construit avec des données valides ne doit pas lever d'exception.
*/
public function testValidConstruction(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret123', PASSWORD_BCRYPT));
$this->assertSame(1, $user->getId());
$this->assertSame('alice', $user->getUsername());
$this->assertSame('alice@example.com', $user->getEmail());
$this->assertSame(User::ROLE_USER, $user->getRole());
}
/**
* Le rôle par défaut doit être 'user'.
*/
public function testDefaultRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame(User::ROLE_USER, $user->getRole());
$this->assertFalse($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
*/
public function testAdminRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_ADMIN);
$this->assertTrue($user->isAdmin());
$this->assertFalse($user->isEditor());
}
/**
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
*/
public function testEditorRole(): void
{
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_EDITOR);
$this->assertFalse($user->isAdmin());
$this->assertTrue($user->isEditor());
}
/**
* Une date de création explicite doit être conservée.
*/
public function testExplicitCreationDate(): void
{
$date = new DateTime('2024-01-15 10:00:00');
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_USER, $date);
$this->assertEquals($date, $user->getCreatedAt());
}
/**
* Sans date explicite, la date de création doit être définie à maintenant.
*/
public function testDefaultCreationDate(): void
{
$before = new DateTime();
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$after = new DateTime();
$this->assertGreaterThanOrEqual($before, $user->getCreatedAt());
$this->assertLessThanOrEqual($after, $user->getCreatedAt());
}
// ── Validation — nom d'utilisateur ─────────────────────────────
/**
* Un nom d'utilisateur de moins de 3 caractères doit lever une exception.
*/
public function testUsernameTooShort(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/3 caractères/');
new User(1, 'ab', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de plus de 50 caractères doit lever une exception.
*/
public function testUsernameTooLong(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/50 caractères/');
new User(1, str_repeat('a', 51), 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un nom d'utilisateur de exactement 3 caractères doit être accepté.
*/
public function testUsernameMinimumLength(): void
{
$user = new User(1, 'ali', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame('ali', $user->getUsername());
}
/**
* Un nom d'utilisateur de exactement 50 caractères doit être accepté.
*/
public function testUsernameMaximumLength(): void
{
$username = str_repeat('a', 50);
$user = new User(1, $username, 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
$this->assertSame($username, $user->getUsername());
}
// ── Validation — email ─────────────────────────────────────────
/**
* Un email invalide doit lever une exception.
*/
public function testInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/email/i');
new User(1, 'alice', 'pas-un-email', password_hash('secret', PASSWORD_BCRYPT));
}
/**
* Un email vide doit lever une exception.
*/
public function testEmptyEmail(): void
{
$this->expectException(InvalidArgumentException::class);
new User(1, 'alice', '', password_hash('secret', PASSWORD_BCRYPT));
}
// ── Validation — hash du mot de passe ──────────────────────────
/**
* Un hash de mot de passe vide doit lever une exception.
*/
public function testEmptyPasswordHash(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/hash/i');
new User(1, 'alice', 'alice@example.com', '');
}
// ── Validation — rôle ──────────────────────────────────────────
/**
* Un rôle invalide doit lever une exception.
*/
public function testInvalidRole(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/rôle/i');
new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), 'superadmin');
}
// ── Hydratation depuis un tableau ──────────────────────────────
/**
* fromArray() doit hydrater correctement l'utilisateur depuis une ligne de base de données.
*/
public function testFromArray(): void
{
$hash = password_hash('secret', PASSWORD_BCRYPT);
$user = User::fromArray([
'id' => 42,
'username' => 'bob',
'email' => 'bob@example.com',
'password_hash' => $hash,
'role' => 'editor',
'created_at' => '2024-06-01 12:00:00',
]);
$this->assertSame(42, $user->getId());
$this->assertSame('bob', $user->getUsername());
$this->assertSame('bob@example.com', $user->getEmail());
$this->assertSame($hash, $user->getPasswordHash());
$this->assertSame('editor', $user->getRole());
$this->assertTrue($user->isEditor());
}
/**
* fromArray() avec une date absente ne doit pas lever d'exception.
*/
public function testFromArrayWithoutDate(): void
{
$user = User::fromArray([
'id' => 1,
'username' => 'alice',
'email' => 'alice@example.com',
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
]);
$this->assertInstanceOf(DateTime::class, $user->getCreatedAt());
}
}