Working state

This commit is contained in:
julien
2026-03-16 09:25:44 +01:00
parent b5a728e669
commit fd3f608059
24 changed files with 249 additions and 502 deletions

1
.gitignore vendored
View File

@@ -24,7 +24,6 @@ public/assets/
database/*.sqlite
database/*.sqlite-shm
database/*.sqlite-wal
database/.provision.lock
# ============================================
# Cache & Logs

View File

@@ -87,7 +87,7 @@ final class MediaController
public function upload(Request $req, Response $res): Response
{
$files = $req->getUploadedFiles();
$uploadedFile = $files['file'] ?? $files['image'] ?? null;
$uploadedFile = $files['image'] ?? null;
if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) {
return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400);
@@ -103,10 +103,7 @@ final class MediaController
return $this->jsonError($res, $e->getMessage(), 500);
}
return $this->jsonOk($res, [
'url' => $url,
'file' => $url,
]);
return $this->jsonSuccess($res, $url);
}
/**
@@ -148,21 +145,21 @@ final class MediaController
}
/**
* Retourne une réponse JSON de succès.
* Retourne une réponse JSON de succès avec l'URL du fichier uploadé.
*
* @param Response $res La réponse HTTP
* @param array<string, mixed> $data Données supplémentaires à fusionner
* @param Response $res La réponse HTTP
* @param string $fileUrl L'URL publique du fichier
*
* @return Response La réponse JSON {"success": true, ...}
* @return Response La réponse JSON {"success": true, "file": "..."}
*/
private function jsonOk(Response $res, array $data = []): Response
private function jsonSuccess(Response $res, string $fileUrl): Response
{
$payload = json_encode(array_merge(['success' => true], $data), JSON_THROW_ON_ERROR);
$res->getBody()->write($payload);
$res->getBody()->write(json_encode([
'success' => true,
'file' => $fileUrl,
], JSON_THROW_ON_ERROR));
return $res
->withHeader('Content-Type', 'application/json')
->withStatus(200);
return $res->withHeader('Content-Type', 'application/json')->withStatus(200);
}
/**

View File

@@ -83,7 +83,7 @@ final class PasswordResetRepositoryTest extends TestCase
public function testCreateSetsCreatedAt(): void
{
$stmt = $this->stmtOk();
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
@@ -104,7 +104,7 @@ final class PasswordResetRepositoryTest extends TestCase
public function testFindActiveByHashReturnsNullWhenMissing(): void
{
$stmt = $this->stmtOk(false);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findActiveByHash('hashquinaexistepas'));
}

View File

@@ -14,13 +14,8 @@ use PDO;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PasswordResetService.
*
* Vérifie la génération de token, la validation et la réinitialisation
* du mot de passe. Les dépendances sont mockées via leurs interfaces.
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetServiceTest extends TestCase
{
/** @var PasswordResetRepositoryInterface&MockObject */
@@ -40,9 +35,9 @@ final class PasswordResetServiceTest extends TestCase
protected function setUp(): void
{
$this->resetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->db = $this->createMock(PDO::class);
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->mailService = $this->createMock(MailServiceInterface::class);
$this->db = $this->createMock(PDO::class);
$this->service = new PasswordResetService(
$this->resetRepository,
@@ -52,13 +47,6 @@ final class PasswordResetServiceTest extends TestCase
);
}
// ── requestReset ───────────────────────────────────────────────
/**
* requestReset() avec un email inconnu ne doit ni envoyer d'email
* ni lever d'exception (protection contre l'énumération d'emails).
*/
public function testRequestResetUnknownEmailReturnsSilently(): void
{
$this->userRepository->method('findByEmail')->willReturn(null);
@@ -68,9 +56,6 @@ final class PasswordResetServiceTest extends TestCase
$this->service->requestReset('inconnu@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit invalider les tokens précédents avant d'en créer un nouveau.
*/
public function testRequestResetInvalidatesPreviousTokens(): void
{
$user = $this->makeUser();
@@ -79,39 +64,39 @@ final class PasswordResetServiceTest extends TestCase
$this->resetRepository->expects($this->once())
->method('invalidateByUserId')
->with($user->getId());
$this->resetRepository->method('create');
$this->mailService->method('send');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit persister un nouveau token en base.
*/
public function testRequestResetCreatesTokenInDatabase(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create')
->with($user->getId(), $this->callback('is_string'), $this->callback('is_string'));
$this->mailService->method('send');
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* requestReset() doit envoyer un email avec le bon destinataire et template.
*/
public function testRequestResetSendsEmailWithCorrectAddress(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->method('create');
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send')
@@ -125,16 +110,15 @@ final class PasswordResetServiceTest extends TestCase
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
/**
* L'URL de réinitialisation dans le contexte de l'email doit contenir le token brut.
*/
public function testRequestResetUrlContainsToken(): void
{
$user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId');
$this->resetRepository->method('create');
$this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once())
->method('send')
@@ -151,84 +135,81 @@ final class PasswordResetServiceTest extends TestCase
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
}
// ── validateToken ──────────────────────────────────────────────
/**
* validateToken() avec un token inexistant doit retourner null.
*/
public function testValidateTokenMissingToken(): void
{
$this->resetRepository->method('findActiveByHash')->willReturn(null);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->willReturn(null);
$result = $this->service->validateToken('tokeninexistant');
$this->assertNull($result);
}
/**
* validateToken() avec un token expiré doit retourner null.
*/
public function testValidateTokenExpiredToken(): void
{
$tokenRaw = 'montokenbrut';
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->with($tokenHash)->willReturn([
'user_id' => 1,
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() - 3600),
'used_at' => null,
]);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 1,
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() - 3600),
'used_at' => null,
]);
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
/**
* validateToken() avec un token valide doit retourner l'utilisateur associé.
*/
public function testValidateTokenValidToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->with($tokenHash)->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())->method('findById')->with($user->getId())->willReturn($user);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$result = $this->service->validateToken($tokenRaw);
$this->assertSame($user, $result);
}
// ── resetPassword ──────────────────────────────────────────────
/**
* resetPassword() avec un token invalide doit lever une InvalidArgumentException.
*/
/**
* validateToken() doit retourner null si le token est valide mais l'utilisateur a été supprimé.
*/
public function testValidateTokenDeletedUserReturnsNull(): void
{
$row = [
'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
];
$tokenRaw = 'token-valide-mais-user-supprime';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->willReturn($row);
$this->userRepository->expects($this->once())->method('findById')->with(999)->willReturn(null);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->expects($this->once())
->method('findById')
->with(999)
->willReturn(null);
$result = $this->service->validateToken('token-valide-mais-user-supprime');
$result = $this->service->validateToken($tokenRaw);
$this->assertNull($result);
}
@@ -238,7 +219,7 @@ final class PasswordResetServiceTest extends TestCase
$this->db->method('beginTransaction')->willReturn(true);
$this->db->method('inTransaction')->willReturn(true);
$this->db->expects($this->once())->method('rollBack');
$this->resetRepository->method('consumeActiveToken')->willReturn(null);
$this->resetRepository->expects($this->once())->method('consumeActiveToken')->willReturn(null);
$this->expectException(InvalidResetTokenException::class);
$this->expectExceptionMessageMatches('/invalide ou a expiré/');
@@ -246,35 +227,17 @@ final class PasswordResetServiceTest extends TestCase
$this->service->resetPassword('tokeninvalide', 'nouveaumdp1');
}
/**
* resetPassword() avec un mot de passe trop court doit lever WeakPasswordException.
*/
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->method('findActiveByHash')->willReturn([
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
]);
$this->userRepository->method('findById')->willReturn($user);
$this->expectException(WeakPasswordException::class);
$this->service->resetPassword($tokenRaw, '1234567');
$this->service->resetPassword('montokenbrut', '1234567');
}
/**
* resetPassword() doit mettre à jour le mot de passe et marquer le token comme consommé.
*/
public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
{
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$user = $this->makeUser();
$tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw);
$this->db->method('beginTransaction')->willReturn(true);
@@ -285,12 +248,15 @@ final class PasswordResetServiceTest extends TestCase
->method('consumeActiveToken')
->with($tokenHash, $this->callback('is_string'))
->willReturn([
'user_id' => $user->getId(),
'user_id' => $user->getId(),
'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null,
'used_at' => null,
]);
$this->userRepository->method('findById')->willReturn($user);
$this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$this->userRepository->expects($this->once())
->method('updatePassword')
@@ -299,12 +265,6 @@ final class PasswordResetServiceTest extends TestCase
$this->service->resetPassword($tokenRaw, 'nouveaumdp1');
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test standard.
*/
private function makeUser(): User
{
return new User(

View File

@@ -133,7 +133,7 @@ final class CategoryRepositoryTest extends TestCase
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}

View File

@@ -202,35 +202,7 @@ final class MediaControllerTest extends ControllerTestCase
$this->assertStatus($res, 200);
$this->assertJsonContentType($res);
$this->assertJsonContains($res, [
'success' => true,
'url' => '/media/abc123.webp',
'file' => '/media/abc123.webp',
]);
}
/**
* upload() doit utiliser 0 comme identifiant utilisateur de secours si la session ne contient pas d'utilisateur.
*/
public function testUploadUsesZeroAsFallbackUserId(): void
{
$file = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(null);
$this->mediaService->expects($this->once())
->method('store')
->with($file, 0)
->willReturn('/media/fallback-user.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContains($res, [
'success' => true,
'url' => '/media/fallback-user.webp',
'file' => '/media/fallback-user.webp',
]);
$this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp']);
}
// ── delete ───────────────────────────────────────────────────────

View File

@@ -1,117 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\MediaController;
use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\UploadedFileInterface;
use Tests\ControllerTestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MediaControllerUploadCompatibilityTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var MediaServiceInterface&MockObject */
private MediaServiceInterface $mediaService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private MediaController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->mediaService = $this->createMock(MediaServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->controller = new MediaController(
$this->view,
$this->mediaService,
$this->flash,
$this->sessionManager,
);
}
public function testUploadAcceptsFileFieldNameUsedByTrumbowyg(): void
{
$file = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(7);
$this->mediaService->expects($this->once())
->method('store')
->with($file, 7)
->willReturn('/media/from-file-field.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['file' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContains($res, [
'success' => true,
'url' => '/media/from-file-field.webp',
'file' => '/media/from-file-field.webp',
]);
}
public function testUploadPrefersFileFieldWhenBothFileAndImageArePresent(): void
{
$fileField = $this->makeValidUploadedFile();
$imageField = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(11);
$this->mediaService->expects($this->once())
->method('store')
->with($fileField, 11)
->willReturn('/media/preferred-file-field.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles([
'file' => $fileField,
'image' => $imageField,
]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContains($res, [
'success' => true,
'url' => '/media/preferred-file-field.webp',
'file' => '/media/preferred-file-field.webp',
]);
}
public function testUploadSuccessResponseContainsBothUrlAndFileKeys(): void
{
$file = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(3);
$this->mediaService->method('store')->willReturn('/media/dual-key.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContains($res, [
'success' => true,
'url' => '/media/dual-key.webp',
'file' => '/media/dual-key.webp',
]);
}
/** @return UploadedFileInterface&MockObject */
private function makeValidUploadedFile(): UploadedFileInterface
{
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
return $file;
}
}

View File

@@ -127,7 +127,7 @@ final class MediaRepositoryTest extends TestCase
public function testFindByUserIdReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99));
}

View File

@@ -211,7 +211,7 @@ final class MediaServiceTest extends TestCase
private function makeUploadedFile(int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->willReturnMap([['uri', '/nonexistent/path']]);
$stream->method('getMetadata')->willReturnCallback(static fn (?string $key = null): mixed => $key === 'uri' ? '/nonexistent/path' : null);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
@@ -227,7 +227,7 @@ final class MediaServiceTest extends TestCase
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->willReturnMap([['uri', $path]]);
$stream->expects($this->once())->method('getMetadata')->with('uri')->willReturn($path);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);

View File

@@ -59,21 +59,6 @@ final class PostExtensionTest extends TestCase
self::assertNull($this->call('post_thumbnail', $post));
}
public function testPostExcerptReturnsHtmlUnchangedWhenContentIsShortEnough(): void
{
$post = new Post(4, 'Titre', '<p><strong>Bonjour</strong> monde</p>', 'titre-4');
self::assertSame('<strong>Bonjour</strong> monde', $this->call('post_excerpt', $post, 50));
}
public function testPostInitialsFallsBackToFirstRawCharacterWhenOnlyStopWordsRemain(): void
{
$post = new Post(5, 'de la', '<p>Contenu</p>', 'slug-5');
self::assertSame('D', $this->call('post_initials', $post));
}
public function testPostInitialsUseMeaningfulWordsAndFallback(): void
{
$post = new Post(1, 'Article de Blog', '<p>Contenu</p>', 'slug');

View File

@@ -159,7 +159,7 @@ final class PostRepositoryTest extends TestCase
public function testFindRecentReturnsEmptyArray(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findRecent(5));
}
@@ -170,7 +170,7 @@ final class PostRepositoryTest extends TestCase
public function testFindRecentPassesLimitCorrectly(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('bindValue')

View File

@@ -110,6 +110,18 @@ final class PostServiceTest extends TestCase
}
/**
* getPostById() retourne l'article trouvé.
*/
public function testGetPostByIdReturnsPost(): void
{
$post = $this->makePost(7, 'Titre', 'slug-7');
$this->repository->expects($this->once())->method('findById')->with(7)->willReturn($post);
self::assertSame($post, $this->service->getPostById(7));
}
// ── createPost ─────────────────────────────────────────────────
/**
@@ -217,6 +229,42 @@ final class PostServiceTest extends TestCase
}
/**
* updatePost() lève NotFoundException si la ligne disparaît entre lecture et écriture.
*/
public function testUpdatePostThrowsWhenRepositoryUpdateAffectsZeroRows(): void
{
$post = $this->makePost(3, 'Titre courant', 'titre-courant', '<p>Ancien contenu</p>');
$this->repository->expects($this->once())->method('findById')->with(3)->willReturn($post);
$this->sanitizer->method('sanitize')->willReturn('<p>Contenu sûr</p>');
$this->repository->expects($this->once())->method('update')->with(3, $this->isInstanceOf(Post::class), 'titre-courant', null)->willReturn(0);
$this->expectException(NotFoundException::class);
$this->service->updatePost(3, 'Titre courant', '<p>Contenu</p>');
}
/**
* updatePost() normalise et rend unique un slug personnalisé.
*/
public function testUpdatePostUsesNormalizedUniqueCustomSlug(): void
{
$current = $this->makePost(4, 'Titre courant', 'ancien-slug', '<p>Ancien contenu</p>');
$this->repository->expects($this->once())->method('findById')->with(4)->willReturn($current);
$this->sanitizer->method('sanitize')->willReturn('<p>Contenu sûr</p>');
$this->repository->expects($this->exactly(2))
->method('slugExists')
->withAnyParameters()
->willReturnOnConsecutiveCalls(true, false);
$this->repository->expects($this->once())
->method('update')
->with(4, $this->isInstanceOf(Post::class), 'nouveau-slug-1', 2)
->willReturn(1);
$this->service->updatePost(4, 'Titre courant', '<p>Contenu</p>', ' Nouveau slug !! ', 2);
}
// ── Helpers ────────────────────────────────────────────────────
/**

View File

@@ -6,43 +6,43 @@ namespace Tests\Shared;
use App\Shared\Bootstrap;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use ReflectionProperty;
use Slim\Factory\AppFactory;
use Slim\App;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class BootstrapTest extends TestCase
{
/** @var array<string, string> */
private array $envBackup = [];
protected function setUp(): void
{
$this->envBackup = $_ENV;
$this->envBackup = [
'APP_AUTO_PROVISION' => $_ENV['APP_AUTO_PROVISION'] ?? null,
];
}
protected function tearDown(): void
{
$_ENV = $this->envBackup;
foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value;
}
}
}
public function testGetContainerReturnsPreloadedContainer(): void
public function testInitializeInfrastructureReturnsPreloadedContainer(): void
{
$bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface {
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
$container = $this->createStub(ContainerInterface::class);
public function has(string $id): bool
{
return false;
}
};
$this->setPrivate($bootstrap, 'container', $container);
$this->setPrivateProperty($bootstrap, 'container', $container);
self::assertSame($container, $bootstrap->getContainer());
self::assertSame($container, $bootstrap->initializeInfrastructure());
self::assertSame($container, $bootstrap->getContainer());
}
public function testCreateHttpAppReturnsPreloadedApp(): void
@@ -50,40 +50,29 @@ final class BootstrapTest extends TestCase
$bootstrap = Bootstrap::create();
$app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'app', $app);
$this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->createHttpApp());
}
public function testInitializeReturnsPreloadedAppWhenAutoProvisioningDisabled(): void
public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void
{
$_ENV['APP_ENV'] = 'production';
$_ENV['APP_AUTO_PROVISION'] = '0';
$bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface {
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
public function has(string $id): bool
{
return false;
}
};
$container = $this->createStub(ContainerInterface::class);
$app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'container', $container);
$this->setPrivateProperty($bootstrap, 'app', $app);
$this->setPrivate($bootstrap, 'container', $container);
$this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->initialize());
}
private function setPrivateProperty(object $object, string $property, mixed $value): void
private function setPrivate(Bootstrap $bootstrap, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($object, $property);
$reflection = new ReflectionProperty($bootstrap, $property);
$reflection->setAccessible(true);
$reflection->setValue($object, $value);
$reflection->setValue($bootstrap, $value);
}
}

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ClientIpResolverTest extends TestCase
{
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
@@ -53,27 +54,4 @@ final class ClientIpResolverTest extends TestCase
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
public function testResolveReturnsRemoteAddrWhenTrustedProxyHasNoForwardedHeader(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => '127.0.0.1',
]);
$resolver = new ClientIpResolver(['127.0.0.1']);
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
public function testResolveTrimsWhitespaceAroundRemoteAndForwardedAddresses(): void
{
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
'REMOTE_ADDR' => ' 127.0.0.1 ',
'HTTP_X_FORWARDED_FOR' => ' 203.0.113.10 , 198.51.100.12',
]);
$resolver = new ClientIpResolver(['*']);
self::assertSame('203.0.113.10', $resolver->resolve($request));
}
}

View File

@@ -7,6 +7,7 @@ use App\Shared\Config;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ConfigTest extends TestCase
{
public function testGetTwigCacheReturnsFalseInDev(): void
@@ -22,25 +23,6 @@ final class ConfigTest extends TestCase
self::assertStringEndsWith('/var/cache/twig', $cachePath);
}
public function testGetDatabasePathReturnsExistingFilePathUnchanged(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';
$dbDir = dirname($dbFile);
if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true);
}
if (!file_exists($dbFile)) {
touch($dbFile);
}
$path = Config::getDatabasePath();
self::assertSame($dbFile, $path);
self::assertFileExists($dbFile);
}
public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';

View File

@@ -46,21 +46,6 @@ final class ExtensionTest extends TestCase
], $extension->getGlobals());
}
public function testSessionExtensionExposesNullDefaultsWhenSessionIsEmpty(): void
{
$_SESSION = [];
$extension = new SessionExtension();
self::assertSame([
'session' => [
'user_id' => null,
'username' => null,
'role' => null,
],
], $extension->getGlobals());
}
public function testCsrfExtensionExposesTokens(): void
{
$storage = [];

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class FlashServiceTest extends TestCase
{
protected function setUp(): void
@@ -34,26 +35,6 @@ final class FlashServiceTest extends TestCase
self::assertArrayNotHasKey('count', $_SESSION['flash']);
}
public function testGetCastsBooleanFalseToEmptyStringAndRemovesIt(): void
{
$_SESSION['flash']['flag'] = false;
$flash = new FlashService();
self::assertSame('', $flash->get('flag'));
self::assertArrayNotHasKey('flag', $_SESSION['flash']);
}
public function testSetOverridesPreviousMessageForSameKey(): void
{
$flash = new FlashService();
$flash->set('notice', 'Premier');
$flash->set('notice', 'Second');
self::assertSame('Second', $flash->get('notice'));
}
public function testGetReturnsNullWhenMissing(): void
{
$flash = new FlashService();

View File

@@ -6,17 +6,17 @@ namespace Tests\Shared;
use App\Shared\Mail\MailService;
use PHPMailer\PHPMailer\PHPMailer;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Slim\Views\Twig;
use Twig\Loader\ArrayLoader;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MailServiceTest extends TestCase
{
public function testCreateMailerUsesSslConfiguration(): void
{
$service = $this->createService('ssl', 465);
/** @var PHPMailer $mailer */
$service = $this->makeService('ssl', 465);
$mailer = $this->invokeCreateMailer($service);
self::assertSame('smtp', $mailer->Mailer);
@@ -27,25 +27,23 @@ final class MailServiceTest extends TestCase
self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure);
self::assertSame(465, $mailer->Port);
self::assertSame(PHPMailer::CHARSET_UTF8, $mailer->CharSet);
self::assertSame('noreply@example.test', $mailer->From);
self::assertSame('no-reply@example.test', $mailer->From);
self::assertSame('Slim Blog', $mailer->FromName);
}
public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void
{
$service = $this->createService('tls', 587);
/** @var PHPMailer $mailer */
$service = $this->makeService('tls', 587);
$mailer = $this->invokeCreateMailer($service);
self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure);
self::assertSame(587, $mailer->Port);
}
private function createService(string $encryption, int $port): MailService
private function makeService(string $encryption, int $port): MailService
{
$twig = new Twig(new ArrayLoader([
'emails/test.twig' => '<p>Hello {{ name }}</p>',
'emails/test.twig' => '<p>Bonjour {{ name }}</p>',
]));
return new MailService(
@@ -55,16 +53,19 @@ final class MailServiceTest extends TestCase
'mailer-user',
'mailer-pass',
$encryption,
'noreply@example.test',
'no-reply@example.test',
'Slim Blog',
);
}
private function invokeCreateMailer(MailService $service): mixed
private function invokeCreateMailer(MailService $service): PHPMailer
{
$method = new \ReflectionMethod($service, 'createMailer');
$method = new ReflectionMethod($service, 'createMailer');
$method->setAccessible(true);
return $method->invoke($service);
/** @var PHPMailer $mailer */
$mailer = $method->invoke($service);
return $mailer;
}
}

View File

@@ -6,12 +6,14 @@ namespace Tests\Shared;
use App\Shared\Exception\NotFoundException;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class NotFoundExceptionTest extends TestCase
{
public function testConstructorFormatsEntityAndIdentifierInMessage(): void
public function testMessageContainsEntityAndIdentifier(): void
{
$exception = new NotFoundException('Article', 15);
$exception = new NotFoundException('Article', 'mon-slug');
self::assertSame('Article introuvable : 15', $exception->getMessage());
self::assertSame('Article introuvable : mon-slug', $exception->getMessage());
}
}

View File

@@ -7,69 +7,70 @@ use App\Shared\Database\Provisioner;
use PDO;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ProvisionerTest extends TestCase
{
private PDO $db;
private string $lockPath;
private bool $lockExistedBefore;
/** @var array<string, string> */
private array $envBackup;
private array $envBackup = [];
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
$this->lockPath = dirname(__DIR__, 2) . '/database/.provision.lock';
$this->lockExistedBefore = file_exists($this->lockPath);
@unlink($this->lockPath);
$this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? null,
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? null,
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? null,
];
$_ENV['ADMIN_USERNAME'] = 'shared-admin';
$_ENV['ADMIN_EMAIL'] = 'shared-admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'strong-secret';
$_ENV['ADMIN_USERNAME'] = 'Admin';
$_ENV['ADMIN_EMAIL'] = 'Admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'secret1234';
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
$_ENV[$key] = $value;
}
@unlink($this->lockPath);
if (!$this->lockExistedBefore && file_exists($this->lockPath)) {
@unlink($this->lockPath);
foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value;
}
}
}
public function testRunAppliesMigrationsSeedsAdminAndCreatesLockFile(): void
public function testRunCreatesProvisionLockAndSeedsAdminUser(): void
{
Provisioner::run($this->db);
self::assertFileExists($this->lockPath);
$migrationCount = (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
self::assertGreaterThan(0, $migrationCount, 'Les migrations doivent être enregistrées');
$row = $this->db->query('SELECT username, email, role FROM users')->fetch();
$admin = $this->db->query("SELECT username, email, role FROM users WHERE username = 'shared-admin'")->fetch();
self::assertIsArray($admin);
self::assertSame('shared-admin@example.com', $admin['email']);
self::assertSame('admin', $admin['role']);
self::assertIsArray($row);
self::assertSame('admin', $row['username']);
self::assertSame('admin@example.com', $row['email']);
self::assertSame('admin', $row['role']);
}
public function testRunIsIdempotentForAdminSeed(): void
public function testRunIsIdempotent(): void
{
Provisioner::run($this->db);
Provisioner::run($this->db);
$adminCount = (int) $this->db->query("SELECT COUNT(*) FROM users WHERE username = 'shared-admin'")->fetchColumn();
self::assertSame(1, $adminCount);
$count = (int) $this->db->query('SELECT COUNT(*) FROM users WHERE username = "admin"')->fetchColumn();
self::assertSame(1, $count);
}
}

View File

@@ -7,6 +7,8 @@ use App\Shared\Routes;
use PHPUnit\Framework\TestCase;
use Slim\Factory\AppFactory;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class RoutesTest extends TestCase
{
public function testRegisterDeclaresExpectedPublicAndProtectedRoutes(): void
@@ -15,46 +17,48 @@ final class RoutesTest extends TestCase
Routes::register($app);
$actual = [];
foreach ($app->getRouteCollector()->getRoutes() as $route) {
$pattern = $route->getPattern();
$methods = $route->getMethods();
if (!isset($actual[$pattern])) {
$actual[$pattern] = [];
}
$methods = array_values(array_diff($route->getMethods(), ['HEAD', 'OPTIONS']));
$actual[$pattern] ??= [];
$actual[$pattern] = array_values(array_unique(array_merge($actual[$pattern], $methods)));
sort($actual[$pattern]);
}
ksort($actual);
$expected = [
'/' => ['GET'],
'/account/password' => ['GET', 'POST'],
'/admin' => ['GET'],
'/admin/categories' => ['GET'],
'/admin/categories/create' => ['POST'],
'/admin/categories/delete/{id}' => ['POST'],
'/admin/media' => ['GET'],
'/admin/media/delete/{id}' => ['POST'],
'/admin/media/upload' => ['POST'],
'/admin/posts' => ['GET'],
'/admin/posts/create' => ['POST'],
'/admin/posts/delete/{id}' => ['POST'],
'/admin/posts/edit/{id}' => ['GET', 'POST'],
'/admin/users' => ['GET'],
'/admin/users/create' => ['GET', 'POST'],
'/admin/users/delete/{id}' => ['POST'],
'/admin/users/role/{id}' => ['POST'],
'/article/{slug}' => ['GET'],
'/rss.xml' => ['GET'],
'/auth/login' => ['GET', 'POST'],
'/auth/logout' => ['POST'],
'/password/forgot' => ['GET', 'POST'],
'/password/reset' => ['GET', 'POST'],
'/account/password' => ['GET', 'POST'],
'/admin' => ['GET'],
'/admin/posts' => ['GET'],
'/admin/posts/edit/{id}' => ['GET', 'POST'],
'/admin/posts/create' => ['POST'],
'/admin/posts/delete/{id}' => ['POST'],
'/admin/media/upload' => ['POST'],
'/admin/media' => ['GET'],
'/admin/media/delete/{id}' => ['POST'],
'/admin/categories' => ['GET'],
'/admin/categories/create' => ['POST'],
'/admin/categories/delete/{id}' => ['POST'],
'/admin/users' => ['GET'],
'/admin/users/create' => ['GET', 'POST'],
'/admin/users/role/{id}' => ['POST'],
'/admin/users/delete/{id}' => ['POST'],
'/rss.xml' => ['GET'],
];
foreach ($expected as $pattern => $methods) {
sort($methods);
}
ksort($expected);
ksort($actual);
self::assertSame($expected, $actual);
}

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class SessionManagerEdgeCasesTest extends TestCase
{
private SessionManager $manager;
@@ -30,14 +31,6 @@ final class SessionManagerEdgeCasesTest extends TestCase
self::assertFalse($this->manager->isAuthenticated());
}
public function testGetUserIdCastsNumericStringToInteger(): void
{
$_SESSION['user_id'] = '42';
self::assertSame(42, $this->manager->getUserId());
self::assertTrue($this->manager->isAuthenticated());
}
public function testSetUserUsesDefaultRoleUser(): void
{
$this->manager->setUser(12, 'julien');

View File

@@ -128,7 +128,7 @@ final class UserRepositoryTest extends TestCase
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
@@ -139,7 +139,7 @@ final class UserRepositoryTest extends TestCase
public function testFindByIdReturnsUserWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowAlice);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
@@ -153,7 +153,7 @@ final class UserRepositoryTest extends TestCase
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')

View File

@@ -130,20 +130,7 @@
serverPath: '/admin/media/upload',
fileFieldName: 'file',
urlPropertyName: 'url',
statusPropertyName: 'success',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
data: [
{
name: '{{ csrf.keys.name }}',
value: '{{ csrf.name }}'
},
{
name: '{{ csrf.keys.value }}',
value: '{{ csrf.value }}'
}
]
statusPropertyName: 'success'
}
}
});