Refatoring : Working state

This commit is contained in:
julien
2026-03-16 16:58:54 +01:00
parent 0453697cd3
commit e0f7c77d6e
54 changed files with 287 additions and 279 deletions

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Http\AccountController as AccountController;
use App\Auth\Http\AccountController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Http\AuthController as AuthController;
use App\Auth\Http\AuthController;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Application\AuthApplicationService as AuthService;
use App\Auth\Application\AuthApplicationService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\UserRepositoryInterface;
@@ -11,11 +11,11 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour la protection anti-brute force de AuthService.
* Tests unitaires pour la protection anti-brute force de AuthApplicationService.
*
* Vérifie le comportement de checkRateLimit(), recordFailure() et
* resetRateLimit(). Les constantes testées correspondent aux valeurs
* définies dans AuthService :
* définies dans AuthApplicationService :
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
*/
@@ -31,7 +31,7 @@ final class AuthServiceRateLimitTest extends TestCase
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
private AuthApplicationService $service;
protected function setUp(): void
{
@@ -39,7 +39,7 @@ final class AuthServiceRateLimitTest extends TestCase
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->service = new AuthApplicationService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Application\AuthApplicationService as AuthService;
use App\Auth\Application\AuthApplicationService;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
@@ -14,7 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour AuthService.
* Tests unitaires pour AuthApplicationService.
*
* Vérifie l'authentification, le changement de mot de passe et la gestion
* des sessions. La création de comptes est couverte par UserServiceTest.
@@ -33,7 +33,7 @@ final class AuthServiceTest extends TestCase
/** @var LoginAttemptRepositoryInterface&MockObject */
private LoginAttemptRepositoryInterface $loginAttemptRepository;
private AuthService $service;
private AuthApplicationService $service;
protected function setUp(): void
{
@@ -41,7 +41,7 @@ final class AuthServiceTest extends TestCase
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
$this->service = new AuthService(
$this->service = new AuthApplicationService(
$this->userRepository,
$this->sessionManager,
$this->loginAttemptRepository,

View File

@@ -3,23 +3,22 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Infrastructure\PdoLoginAttemptRepository as LoginAttemptRepository;
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour LoginAttemptRepository.
* Tests unitaires pour PdoLoginAttemptRepository.
*
* Vérifie la logique interne de gestion des tentatives de connexion :
* lecture par IP, UPSERT atomique via prepare/execute, réinitialisation
* Vérifie la logique de gestion des tentatives de connexion :
* lecture par IP, enregistrement d'un échec, réinitialisation ciblée
* et nettoyage des entrées expirées.
*
* recordFailure() utilise un UPSERT SQL atomique (ON CONFLICT DO UPDATE)
* pour éliminer la race condition du pattern SELECT + INSERT/UPDATE.
* Les tests vérifient que prepare() est appelé avec le bon SQL et que
* execute() reçoit les bons paramètres.
* Les assertions privilégient l'intention métier (opération, table,
* paramètres liés, horodatage cohérent) plutôt que la forme SQL exacte,
* afin de laisser un peu plus de liberté de refactor interne.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
@@ -30,7 +29,7 @@ final class LoginAttemptRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private LoginAttemptRepository $repository;
private PdoLoginAttemptRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
@@ -38,7 +37,7 @@ final class LoginAttemptRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new LoginAttemptRepository($this->db);
$this->repository = new PdoLoginAttemptRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
@@ -107,8 +106,8 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── recordFailure — UPSERT atomique ────────────────────────────
/**
* recordFailure() doit utiliser un UPSERT SQL (ON CONFLICT DO UPDATE)
* via prepare/execute — garantie de l'atomicité.
* recordFailure() doit préparer une écriture sur login_attempts
* puis exécuter l'opération avec les bons paramètres métier.
*/
public function testRecordFailureUsesUpsertSql(): void
{
@@ -117,8 +116,9 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->logicalAnd(
$this->stringContains('INSERT INTO login_attempts'),
$this->stringContains('ON CONFLICT'),
$this->stringContains('login_attempts'),
$this->stringContains('attempts'),
$this->stringContains('locked_until'),
))
->willReturn($stmt);
@@ -230,7 +230,7 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── resetForIp ─────────────────────────────────────────────────
/**
* resetForIp() doit préparer un DELETE ciblant la bonne IP.
* resetForIp() doit préparer une suppression ciblant la bonne IP.
*/
public function testResetForIpCallsDeleteWithCorrectIp(): void
{
@@ -239,7 +239,10 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->with($this->logicalAnd(
$this->stringContains('login_attempts'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -253,7 +256,7 @@ final class LoginAttemptRepositoryTest extends TestCase
// ── deleteExpired ──────────────────────────────────────────────
/**
* deleteExpired() doit préparer un DELETE ciblant locked_until expiré
* deleteExpired() doit préparer une suppression sur login_attempts
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
*/
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
@@ -262,7 +265,10 @@ final class LoginAttemptRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM login_attempts'))
->with($this->logicalAnd(
$this->stringContains('login_attempts'),
$this->stringContains('DELETE'),
))
->willReturn($stmt);
$stmt->expects($this->once())

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\AuthServiceInterface;
use App\Auth\Http\PasswordResetController as PasswordResetController;
use App\Auth\Http\PasswordResetController;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetServiceInterface;
use App\Shared\Http\ClientIpResolver;

View File

@@ -3,18 +3,20 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PasswordResetRepository.
* Tests unitaires pour PdoPasswordResetRepository.
*
* Vérifie que chaque méthode construit le bon SQL avec les bons paramètres,
* notamment la logique de non-suppression des tokens (used_at) et la
* condition AND used_at IS NULL pour les tokens actifs.
* Vérifie les opérations de persistance des tokens de réinitialisation.
*
* Les assertions privilégient l'intention (lecture, création, invalidation,
* consommation atomique) et les paramètres métier importants plutôt que
* la forme exacte du SQL.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
@@ -25,7 +27,7 @@ final class PasswordResetRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private PasswordResetRepository $repository;
private PdoPasswordResetRepository $repository;
/**
* Initialise le mock PDO et le dépôt avant chaque test.
@@ -33,7 +35,7 @@ final class PasswordResetRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PasswordResetRepository($this->db);
$this->repository = new PdoPasswordResetRepository($this->db);
}
// ── Helper ─────────────────────────────────────────────────────
@@ -131,8 +133,8 @@ final class PasswordResetRepositoryTest extends TestCase
}
/**
* findActiveByHash() doit inclure AND used_at IS NULL dans le SQL
* pour n'obtenir que les tokens non consommés.
* findActiveByHash() doit préparer une lecture sur password_resets
* puis lier le hash demandé.
*/
public function testFindActiveByHashFiltersOnNullUsedAt(): void
{
@@ -141,7 +143,10 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('token_hash'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -155,8 +160,8 @@ final class PasswordResetRepositoryTest extends TestCase
// ── invalidateByUserId ─────────────────────────────────────────
/**
* invalidateByUserId() doit préparer un UPDATE renseignant :used_at
* pour tous les tokens non consommés de l'utilisateur.
* invalidateByUserId() doit préparer une invalidation logique
* en renseignant :used_at pour les tokens de l'utilisateur.
*/
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
{
@@ -164,7 +169,10 @@ final class PasswordResetRepositoryTest extends TestCase
$stmt = $this->stmtOk();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE password_resets'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
))
->willReturn($stmt);
$stmt->expects($this->once())
@@ -179,8 +187,9 @@ final class PasswordResetRepositoryTest extends TestCase
}
/**
* invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL
* pour ne cibler que les tokens encore actifs.
* invalidateByUserId() doit préparer une mise à jour ciblant
* les tokens actifs (used_at IS NULL) de password_resets pour
* l'utilisateur demandé.
*/
public function testInvalidateByUserIdFiltersOnActiveTokens(): void
{
@@ -188,7 +197,11 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('used_at IS NULL'))
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('user_id'),
$this->stringContains('used_at IS NULL'),
))
->willReturn($stmt);
$this->repository->invalidateByUserId(1);
@@ -215,8 +228,8 @@ final class PasswordResetRepositoryTest extends TestCase
// ── consumeActiveToken ────────────────────────────────────────
/**
* consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer
* et retourner le token en une seule opération atomique.
* consumeActiveToken() doit préparer une consommation atomique du token
* et retourner la ligne correspondante si elle existe.
*/
public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void
{
@@ -225,10 +238,10 @@ final class PasswordResetRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->callback(fn (string $sql): bool =>
str_contains($sql, 'UPDATE password_resets')
&& str_contains($sql, 'used_at IS NULL')
&& str_contains($sql, 'RETURNING *')
->with($this->logicalAnd(
$this->stringContains('password_resets'),
$this->stringContains('UPDATE'),
$this->stringContains('RETURNING'),
))
->willReturn($stmt);

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\Infrastructure\PdoPasswordResetRepository as PasswordResetRepository;
use App\Auth\Application\PasswordResetApplicationService as PasswordResetService;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
use App\Auth\Application\PasswordResetApplicationService;
use App\Shared\Database\Migrator;
use App\Shared\Mail\MailServiceInterface;
use App\User\User;
use App\User\Infrastructure\PdoUserRepository as UserRepository;
use App\User\Infrastructure\PdoUserRepository;
use PDO;
use PHPUnit\Framework\TestCase;
@@ -18,9 +18,9 @@ use PHPUnit\Framework\TestCase;
final class PasswordResetServiceIntegrationTest extends TestCase
{
private PDO $db;
private PasswordResetService $service;
private UserRepository $users;
private PasswordResetRepository $resets;
private PasswordResetApplicationService $service;
private PdoUserRepository $users;
private PdoPasswordResetRepository $resets;
protected function setUp(): void
{
@@ -31,15 +31,15 @@ final class PasswordResetServiceIntegrationTest extends TestCase
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->users = new UserRepository($this->db);
$this->resets = new PasswordResetRepository($this->db);
$this->users = new PdoUserRepository($this->db);
$this->resets = new PdoPasswordResetRepository($this->db);
$mail = new class implements MailServiceInterface {
public function send(string $to, string $subject, string $template, array $context = []): void
{
}
};
$this->service = new PasswordResetService($this->resets, $this->users, $mail, $this->db);
$this->service = new PasswordResetApplicationService($this->resets, $this->users, $mail, $this->db);
}
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void

View File

@@ -5,7 +5,7 @@ namespace Tests\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetRepositoryInterface;
use App\Auth\Application\PasswordResetApplicationService as PasswordResetService;
use App\Auth\Application\PasswordResetApplicationService;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
@@ -27,7 +27,7 @@ final class PasswordResetServiceTest extends TestCase
/** @var MailServiceInterface&MockObject */
private MailServiceInterface $mailService;
private PasswordResetService $service;
private PasswordResetApplicationService $service;
/** @var PDO&MockObject */
private PDO $db;
@@ -39,7 +39,7 @@ final class PasswordResetServiceTest extends TestCase
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->db = $this->createMock(PDO::class);
$this->service = new PasswordResetService(
$this->service = new PasswordResetApplicationService(
$this->resetRepository,
$this->userRepository,
$this->mailService,

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\Http\CategoryController as CategoryController;
use App\Category\Http\CategoryController;
use App\Category\CategoryServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Pagination\PaginatedResult;

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\Infrastructure\PdoCategoryRepository as CategoryRepository;
use App\Category\Infrastructure\PdoCategoryRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryRepository.
* Tests unitaires pour PdoCategoryRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
@@ -25,7 +25,7 @@ final class CategoryRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private CategoryRepository $repository;
private PdoCategoryRepository $repository;
/**
* Données représentant une ligne catégorie en base de données.
@@ -37,7 +37,7 @@ final class CategoryRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new CategoryRepository($this->db);
$this->repository = new PdoCategoryRepository($this->db);
$this->rowPhp = [
'id' => 1,
@@ -107,18 +107,15 @@ final class CategoryRepositoryTest extends TestCase
}
/**
* findAll() interroge la table 'categories' triée par name ASC.
* findAll() interroge bien la table `categories`.
*/
public function testFindAllQueriesWithAlphabeticOrder(): void
public function testFindAllRequestsCategoriesQuery(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('categories'),
$this->stringContains('name ASC'),
))
->with($this->stringContains('FROM categories'))
->willReturn($stmt);
$this->repository->findAll();
@@ -163,7 +160,7 @@ final class CategoryRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 42]);
->with($this->callback(fn (array $params): bool => in_array(42, $params, true)));
$this->repository->findById(42);
}
@@ -206,7 +203,7 @@ final class CategoryRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':slug' => 'php']);
->with($this->callback(fn (array $params): bool => in_array('php', $params, true)));
$this->repository->findBySlug('php');
}
@@ -268,7 +265,7 @@ final class CategoryRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 3]);
->with($this->callback(fn (array $params): bool => in_array(3, $params, true)));
$this->repository->delete(3);
}
@@ -330,7 +327,7 @@ final class CategoryRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':name' => 'PHP']);
->with($this->callback(fn (array $params): bool => in_array('PHP', $params, true)));
$this->repository->nameExists('PHP');
}
@@ -369,12 +366,12 @@ final class CategoryRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('posts'))
->with($this->stringContains('FROM posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 5]);
->with($this->callback(fn (array $params): bool => in_array(5, $params, true)));
$this->repository->hasPost(5);
}

View File

@@ -5,12 +5,12 @@ namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryRepositoryInterface;
use App\Category\Application\CategoryApplicationService as CategoryService;
use App\Category\Application\CategoryApplicationService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryService.
* Tests unitaires pour CategoryApplicationService.
*
* Vérifie la création (génération de slug, unicité du nom, validation du modèle)
* et la suppression (blocage si articles rattachés).
@@ -22,12 +22,12 @@ final class CategoryServiceTest extends TestCase
/** @var CategoryRepositoryInterface&MockObject */
private CategoryRepositoryInterface $repository;
private CategoryService $service;
private CategoryApplicationService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(CategoryRepositoryInterface::class);
$this->service = new CategoryService($this->repository);
$this->service = new CategoryApplicationService($this->repository);
}

View File

@@ -7,7 +7,7 @@ use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use App\Media\Media;
use App\Media\Http\MediaController as MediaController;
use App\Media\Http\MediaController;
use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use App\Media\Infrastructure\PdoMediaRepository as MediaRepository;
use App\Media\Infrastructure\PdoMediaRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour MediaRepository.
* Tests unitaires pour PdoMediaRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
@@ -25,7 +25,7 @@ final class MediaRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private MediaRepository $repository;
private PdoMediaRepository $repository;
/**
* Données représentant une ligne média en base de données.
@@ -37,7 +37,7 @@ final class MediaRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new MediaRepository($this->db);
$this->repository = new PdoMediaRepository($this->db);
$this->rowImage = [
'id' => 1,
@@ -70,6 +70,7 @@ final class MediaRepositoryTest extends TestCase
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
@@ -100,23 +101,21 @@ final class MediaRepositoryTest extends TestCase
}
/**
* findAll() interroge la table 'media' triée par id DESC.
* findAll() interroge bien la table `media`.
*/
public function testFindAllQueriesWithDescendingOrder(): void
public function testFindAllRequestsMediaQuery(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('media'),
$this->stringContains('id DESC'),
))
->with($this->stringContains('FROM media'))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findByUserId ───────────────────────────────────────────────
/**
@@ -154,11 +153,12 @@ final class MediaRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':user_id' => 5]);
->with($this->callback(fn (array $params): bool => in_array(5, $params, true)));
$this->repository->findByUserId(5);
}
// ── findById ───────────────────────────────────────────────────
/**
@@ -196,13 +196,58 @@ final class MediaRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 8]);
->with($this->callback(fn (array $params): bool => in_array(8, $params, true)));
$this->repository->findById(8);
}
// ── findByHash ─────────────────────────────────────────────────
/**
* findByHash() retourne null si aucun média ne correspond au hash.
*/
public function testFindByHashReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findByHash(str_repeat('b', 64)));
}
/**
* findByHash() retourne une instance Media si le hash existe (doublon détecté).
*/
public function testFindByHashReturnsDuplicateMedia(): void
{
$stmt = $this->stmtForRead(row: $this->rowImage);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByHash(str_repeat('a', 64));
$this->assertInstanceOf(Media::class, $result);
$this->assertSame(str_repeat('a', 64), $result->getHash());
}
/**
* findByHash() exécute avec le bon hash.
*/
public function testFindByHashQueriesWithCorrectHash(): void
{
$hash = str_repeat('c', 64);
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $params): bool => in_array($hash, $params, true)));
$this->repository->findByHash($hash);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec les bonnes colonnes.
*/
@@ -243,6 +288,7 @@ final class MediaRepositoryTest extends TestCase
$this->assertSame(15, $this->repository->create($media));
}
// ── delete ─────────────────────────────────────────────────────
/**
@@ -259,7 +305,7 @@ final class MediaRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 4]);
->with($this->callback(fn (array $params): bool => in_array(4, $params, true)));
$this->repository->delete(4);
}

View File

@@ -5,7 +5,7 @@ namespace Tests\Media;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\Application\MediaApplicationService as MediaService;
use App\Media\Application\MediaApplicationService;
use App\Media\Infrastructure\LocalMediaStorage;
use App\Post\PostRepositoryInterface;
use PDOException;
@@ -25,7 +25,7 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
private string $uploadDir;
private MediaService $service;
private MediaApplicationService $service;
protected function setUp(): void
{
@@ -34,7 +34,7 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService($this->repository, $this->postRepository, new LocalMediaStorage($this->uploadDir), '/media', 5 * 1024 * 1024);
$this->service = new MediaApplicationService($this->repository, $this->postRepository, new LocalMediaStorage($this->uploadDir), '/media', 5 * 1024 * 1024);
}
protected function tearDown(): void

View File

@@ -6,7 +6,7 @@ namespace Tests\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface;
use App\Media\Application\MediaApplicationService as MediaService;
use App\Media\Application\MediaApplicationService;
use App\Media\Infrastructure\LocalMediaStorage;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase;
@@ -24,7 +24,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(null);
$service = new MediaService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 1000);
$service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 1000);
$this->expectException(StorageException::class);
$service->store($file, 1);
@@ -42,7 +42,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
$file->method('getSize')->willReturn(999999);
$file->method('getStream')->willReturn($stream);
$service = new MediaService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 100);
$service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage('/tmp'), '/media', 100);
$this->expectException(FileTooLargeException::class);
$service->store($file, 1);

View File

@@ -5,7 +5,7 @@ namespace Tests\Media;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\MediaRepositoryInterface;
use App\Media\Application\MediaApplicationService as MediaService;
use App\Media\Application\MediaApplicationService;
use App\Media\Infrastructure\LocalMediaStorage;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase;
@@ -32,7 +32,7 @@ final class MediaServiceInvalidMimeTest extends TestCase
$file->method('getStream')->willReturn($stream);
$file->method('getClientFilename')->willReturn('photo.png');
$service = new MediaService($repo, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000);
$service = new MediaApplicationService($repo, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000);
try {
$this->expectException(InvalidMimeTypeException::class);

View File

@@ -5,7 +5,7 @@ namespace Tests\Media;
use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface;
use App\Media\Application\MediaApplicationService as MediaService;
use App\Media\Application\MediaApplicationService;
use App\Media\Infrastructure\LocalMediaStorage;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\TestCase;
@@ -29,7 +29,7 @@ final class MediaServiceInvalidTempPathTest extends TestCase
$postRepo = $this->createMock(PostRepositoryInterface::class);
$service = new MediaService($repository, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000);
$service = new MediaApplicationService($repository, $postRepo, new LocalMediaStorage(sys_get_temp_dir()), '/media', 500000);
$this->expectException(StorageException::class);
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');

View File

@@ -7,7 +7,7 @@ use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\Application\MediaApplicationService as MediaService;
use App\Media\Application\MediaApplicationService;
use App\Media\Infrastructure\LocalMediaStorage;
use App\Post\PostRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
@@ -16,7 +16,7 @@ use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Tests unitaires pour MediaService.
* Tests unitaires pour MediaApplicationService.
*
* Stratégie : les opérations sur le système de fichiers réel (finfo, GD,
* copy, moveTo) sont exercées via de vrais fichiers JPEG temporaires ;
@@ -39,7 +39,7 @@ final class MediaServiceTest extends TestCase
private string $uploadDir;
private MediaService $service;
private MediaApplicationService $service;
protected function setUp(): void
{
@@ -48,7 +48,7 @@ final class MediaServiceTest extends TestCase
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService(
$this->service = new MediaApplicationService(
mediaRepository: $this->repository,
postRepository: $this->postRepository,
mediaStorage: new LocalMediaStorage($this->uploadDir),

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\Infrastructure\PdoPostRepository as PostRepository;
use App\Post\Infrastructure\PdoPostRepository;
use App\Post\PostRepositoryInterface;
use App\Post\Application\PostApplicationService as PostService;
use App\Post\Application\PostApplicationService;
use App\Shared\Database\Migrator;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
@@ -34,11 +34,11 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase
public function testUpdatePostThrowsWhenRowDisappearsBetweenReadAndWrite(): void
{
$realRepo = new PostRepository($this->db);
$realRepo = new PdoPostRepository($this->db);
$repo = new class($realRepo) implements PostRepositoryInterface {
private bool $deleted = false;
public function __construct(private readonly PostRepository $inner) {}
public function __construct(private readonly PdoPostRepository $inner) {}
public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); }
public function findPage(int $limit, int $offset, ?int $categoryId = null): array { return $this->inner->findPage($limit, $offset, $categoryId); }
@@ -70,7 +70,7 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase
public function sanitize(string $html): string { return $html; }
};
$service = new PostService($repo, $sanitizer);
$service = new PostApplicationService($repo, $sanitizer);
$this->expectException(NotFoundException::class);
$service->updatePost(1, 'Titre modifié', '<p>Contenu modifié</p>');

View File

@@ -6,7 +6,7 @@ namespace Tests\Post;
use App\Category\Category;
use App\Category\CategoryServiceInterface;
use App\Post\Post;
use App\Post\Http\PostController as PostController;
use App\Post\Http\PostController;
use App\Post\PostServiceInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\Infrastructure\TwigPostExtension as PostExtension;
use App\Post\Infrastructure\TwigPostExtension;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
@@ -16,7 +16,7 @@ final class PostExtensionTest extends TestCase
protected function setUp(): void
{
$extension = new PostExtension();
$extension = new TwigPostExtension();
$this->functions = [];
foreach ($extension->getFunctions() as $function) {

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace Tests\Post;
use App\Post\Infrastructure\PdoPostRepository as PostRepository;
use App\Post\Infrastructure\PdoPostRepository;
use App\Shared\Database\Migrator;
use PDO;
use PHPUnit\Framework\TestCase;
@@ -31,7 +31,7 @@ final class PostFtsUsernameSyncIntegrationTest extends TestCase
{
$this->db->exec("UPDATE users SET username = 'alice_renamed' WHERE id = 1");
$results = (new PostRepository($this->db))->search('alice_renamed');
$results = (new PdoPostRepository($this->db))->search('alice_renamed');
self::assertCount(1, $results);
self::assertSame('alice_renamed', $results[0]->getAuthorUsername());

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\Infrastructure\PdoPostRepository as PostRepository;
use App\Post\Infrastructure\PdoPostRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostRepository.
* Tests unitaires pour PdoPostRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
* Vérifie l'intention des requêtes et les valeurs retournées
* sans figer inutilement tous les détails d'implémentation SQL.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
@@ -25,7 +25,7 @@ final class PostRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private PostRepository $repository;
private PdoPostRepository $repository;
/**
* Données représentant une ligne article en base de données (avec JOINs).
@@ -37,7 +37,7 @@ final class PostRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PostRepository($this->db);
$this->repository = new PdoPostRepository($this->db);
$this->rowPost = [
'id' => 1,
@@ -116,14 +116,20 @@ final class PostRepositoryTest extends TestCase
}
/**
* findAll() sans filtre appelle query() et non prepare()
* (pas de paramètre à lier).
* findAll() sans filtre interroge bien la table `posts`.
*/
public function testFindAllWithoutFilterUsesQueryNotPrepare(): void
public function testFindAllWithoutFilterRequestsPostsQuery(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())->method('query')->willReturn($stmt);
$this->db->expects($this->never())->method('prepare');
$this->db->expects($this->once())
->method('query')
->with($this->callback(
static fn (string $sql): bool => str_contains(
strtolower(preg_replace('/\s+/', ' ', $sql)),
'from posts'
)
))
->willReturn($stmt);
$this->repository->findAll();
}
@@ -138,12 +144,17 @@ final class PostRepositoryTest extends TestCase
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('category_id'))
->with($this->callback(
static fn (string $sql): bool => str_contains(
strtolower(preg_replace('/\s+/', ' ', $sql)),
'from posts'
) && str_contains($sql, 'category_id')
))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':category_id' => 3]);
->with($this->callback(fn (array $params): bool => in_array(3, $params, true)));
$this->repository->findAll(3);
}
@@ -215,7 +226,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':author_id' => 7]);
->with($this->callback(fn (array $params): bool => in_array(7, $params, true)));
$this->repository->findByUserId(7);
}
@@ -230,7 +241,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':author_id' => 7, ':category_id' => 3]);
->with($this->callback(fn (array $params): bool => count($params) === 2 && in_array(7, $params, true) && in_array(3, $params, true)));
$this->repository->findByUserId(7, 3);
}
@@ -273,7 +284,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':slug' => 'mon-article']);
->with($this->callback(fn (array $params): bool => in_array('mon-article', $params, true)));
$this->repository->findBySlug('mon-article');
}
@@ -316,7 +327,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 12]);
->with($this->callback(fn (array $params): bool => in_array(12, $params, true)));
$this->repository->findById(12);
}
@@ -455,7 +466,7 @@ final class PostRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 6]);
->with($this->callback(fn (array $params): bool => in_array(6, $params, true)));
$this->repository->delete(6);
}

View File

@@ -5,7 +5,7 @@ namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepositoryInterface;
use App\Post\Application\PostApplicationService as PostService;
use App\Post\Application\PostApplicationService;
use App\Shared\Html\HtmlSanitizerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -19,13 +19,13 @@ final class PostServiceCoverageTest extends TestCase
/** @var HtmlSanitizerInterface&MockObject */
private HtmlSanitizerInterface $sanitizer;
private PostService $service;
private PostApplicationService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(PostRepositoryInterface::class);
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
$this->service = new PostService($this->repository, $this->sanitizer);
$this->service = new PostApplicationService($this->repository, $this->sanitizer);
}
public function testGetAllPostsPassesCategoryIdToRepository(): void

View File

@@ -5,14 +5,14 @@ namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepositoryInterface;
use App\Post\Application\PostApplicationService as PostService;
use App\Post\Application\PostApplicationService;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostService.
* Tests unitaires pour PostApplicationService.
*
* Couvre la création, la mise à jour, la suppression et les lectures.
* HtmlSanitizerInterface et PostRepository sont mockés pour isoler la logique métier.
@@ -26,13 +26,13 @@ final class PostServiceTest extends TestCase
/** @var HtmlSanitizerInterface&MockObject */
private HtmlSanitizerInterface $sanitizer;
private PostService $service;
private PostApplicationService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(PostRepositoryInterface::class);
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
$this->service = new PostService($this->repository, $this->sanitizer);
$this->service = new PostApplicationService($this->repository, $this->sanitizer);
}

View File

@@ -5,7 +5,7 @@ namespace Tests\Post;
use App\Post\Post;
use App\Post\PostServiceInterface;
use App\Post\Http\RssController as RssController;
use App\Post\Http\RssController;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;

View File

@@ -10,7 +10,7 @@ use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\Http\UserController as UserController;
use App\User\Http\UserController;
use App\User\UserServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestBase;

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Tests\User;
use App\User\User;
use App\User\Infrastructure\PdoUserRepository as UserRepository;
use App\User\Infrastructure\PdoUserRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserRepository.
* Tests unitaires pour PdoUserRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
@@ -25,7 +25,7 @@ final class UserRepositoryTest extends TestCase
/** @var PDO&MockObject */
private PDO $db;
private UserRepository $repository;
private PdoUserRepository $repository;
/**
* Données représentant une ligne utilisateur en base de données.
@@ -40,7 +40,7 @@ final class UserRepositoryTest extends TestCase
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new UserRepository($this->db);
$this->repository = new PdoUserRepository($this->db);
$this->rowAlice = [
'id' => 1,
@@ -102,18 +102,15 @@ final class UserRepositoryTest extends TestCase
}
/**
* findAll() doit interroger la table 'users' avec un tri par created_at ASC.
* findAll() interroge bien la table `users`.
*/
public function testFindAllQueriesWithAscendingOrder(): void
public function testFindAllRequestsUsersQuery(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('users'),
$this->stringContains('created_at ASC'),
))
->with($this->stringContains('FROM users'))
->willReturn($stmt);
$this->repository->findAll();
@@ -157,7 +154,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 42]);
->with($this->callback(fn (array $params): bool => in_array(42, $params, true)));
$this->repository->findById(42);
}
@@ -200,7 +197,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':username' => 'alice']);
->with($this->callback(fn (array $params): bool => in_array('alice', $params, true)));
$this->repository->findByUsername('alice');
}
@@ -243,7 +240,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':email' => 'alice@example.com']);
->with($this->callback(fn (array $params): bool => in_array('alice@example.com', $params, true)));
$this->repository->findByEmail('alice@example.com');
}
@@ -308,7 +305,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':password_hash' => $newHash, ':id' => 1]);
->with($this->callback(fn (array $params): bool => in_array($newHash, $params, true) && in_array(1, $params, true)));
$this->repository->updatePassword(1, $newHash);
}
@@ -329,7 +326,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':role' => User::ROLE_EDITOR, ':id' => 1]);
->with($this->callback(fn (array $params): bool => in_array(User::ROLE_EDITOR, $params, true) && in_array(1, $params, true)));
$this->repository->updateRole(1, User::ROLE_EDITOR);
}
@@ -351,7 +348,7 @@ final class UserRepositoryTest extends TestCase
$stmt->expects($this->once())
->method('execute')
->with([':id' => 7]);
->with($this->callback(fn (array $params): bool => in_array(7, $params, true)));
$this->repository->delete(7);
}

View File

@@ -9,12 +9,12 @@ use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use App\User\Application\UserApplicationService as UserService;
use App\User\Application\UserApplicationService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour UserService.
* Tests unitaires pour UserApplicationService.
*
* 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.
@@ -27,12 +27,12 @@ final class UserServiceTest extends TestCase
/** @var UserRepositoryInterface&MockObject */
private UserRepositoryInterface $userRepository;
private UserService $service;
private UserApplicationService $service;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->userRepository);
$this->service = new UserApplicationService($this->userRepository);
}