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
database/*.sqlite-shm database/*.sqlite-shm
database/*.sqlite-wal database/*.sqlite-wal
database/.provision.lock
# ============================================ # ============================================
# Cache & Logs # Cache & Logs

View File

@@ -87,7 +87,7 @@ final class MediaController
public function upload(Request $req, Response $res): Response public function upload(Request $req, Response $res): Response
{ {
$files = $req->getUploadedFiles(); $files = $req->getUploadedFiles();
$uploadedFile = $files['file'] ?? $files['image'] ?? null; $uploadedFile = $files['image'] ?? null;
if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) { if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) {
return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400); 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->jsonError($res, $e->getMessage(), 500);
} }
return $this->jsonOk($res, [ return $this->jsonSuccess($res, $url);
'url' => $url,
'file' => $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 Response $res La réponse HTTP
* @param array<string, mixed> $data Données supplémentaires à fusionner * @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(json_encode([
$res->getBody()->write($payload); 'success' => true,
'file' => $fileUrl,
], JSON_THROW_ON_ERROR));
return $res return $res->withHeader('Content-Type', 'application/json')->withStatus(200);
->withHeader('Content-Type', 'application/json')
->withStatus(200);
} }
/** /**

View File

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

View File

@@ -14,13 +14,8 @@ use PDO;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; 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] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PasswordResetServiceTest extends TestCase final class PasswordResetServiceTest extends TestCase
{ {
/** @var PasswordResetRepositoryInterface&MockObject */ /** @var PasswordResetRepositoryInterface&MockObject */
@@ -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 public function testRequestResetUnknownEmailReturnsSilently(): void
{ {
$this->userRepository->method('findByEmail')->willReturn(null); $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'); $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 public function testRequestResetInvalidatesPreviousTokens(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
@@ -79,39 +64,39 @@ final class PasswordResetServiceTest extends TestCase
$this->resetRepository->expects($this->once()) $this->resetRepository->expects($this->once())
->method('invalidateByUserId') ->method('invalidateByUserId')
->with($user->getId()); ->with($user->getId());
$this->resetRepository->method('create'); $this->resetRepository->expects($this->once())
$this->mailService->method('send'); ->method('create');
$this->mailService->expects($this->once())
->method('send');
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); $this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
} }
/**
* requestReset() doit persister un nouveau token en base.
*/
public function testRequestResetCreatesTokenInDatabase(): void public function testRequestResetCreatesTokenInDatabase(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user); $this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId'); $this->resetRepository->expects($this->once())
->method('invalidateByUserId');
$this->resetRepository->expects($this->once()) $this->resetRepository->expects($this->once())
->method('create') ->method('create')
->with($user->getId(), $this->callback('is_string'), $this->callback('is_string')); ->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'); $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 public function testRequestResetSendsEmailWithCorrectAddress(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user); $this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId'); $this->resetRepository->expects($this->once())
$this->resetRepository->method('create'); ->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once()) $this->mailService->expects($this->once())
->method('send') ->method('send')
@@ -125,16 +110,15 @@ final class PasswordResetServiceTest extends TestCase
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); $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 public function testRequestResetUrlContainsToken(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
$this->userRepository->method('findByEmail')->willReturn($user); $this->userRepository->method('findByEmail')->willReturn($user);
$this->resetRepository->method('invalidateByUserId'); $this->resetRepository->expects($this->once())
$this->resetRepository->method('create'); ->method('invalidateByUserId');
$this->resetRepository->expects($this->once())
->method('create');
$this->mailService->expects($this->once()) $this->mailService->expects($this->once())
->method('send') ->method('send')
@@ -151,30 +135,26 @@ final class PasswordResetServiceTest extends TestCase
$this->service->requestReset('alice@example.com', 'https://blog.exemple.com'); $this->service->requestReset('alice@example.com', 'https://blog.exemple.com');
} }
// ── validateToken ──────────────────────────────────────────────
/**
* validateToken() avec un token inexistant doit retourner null.
*/
public function testValidateTokenMissingToken(): void public function testValidateTokenMissingToken(): void
{ {
$this->resetRepository->method('findActiveByHash')->willReturn(null); $this->resetRepository->expects($this->once())
->method('findActiveByHash')
->willReturn(null);
$result = $this->service->validateToken('tokeninexistant'); $result = $this->service->validateToken('tokeninexistant');
$this->assertNull($result); $this->assertNull($result);
} }
/**
* validateToken() avec un token expiré doit retourner null.
*/
public function testValidateTokenExpiredToken(): void public function testValidateTokenExpiredToken(): void
{ {
$tokenRaw = 'montokenbrut'; $tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw); $tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->with($tokenHash)->willReturn([ $this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 1, 'user_id' => 1,
'token_hash' => $tokenHash, 'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() - 3600), 'expires_at' => date('Y-m-d H:i:s', time() - 3600),
@@ -186,49 +166,50 @@ final class PasswordResetServiceTest extends TestCase
$this->assertNull($result); $this->assertNull($result);
} }
/**
* validateToken() avec un token valide doit retourner l'utilisateur associé.
*/
public function testValidateTokenValidToken(): void public function testValidateTokenValidToken(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
$tokenRaw = 'montokenbrut'; $tokenRaw = 'montokenbrut';
$tokenHash = hash('sha256', $tokenRaw); $tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->with($tokenHash)->willReturn([ $this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => $user->getId(), 'user_id' => $user->getId(),
'token_hash' => $tokenHash, 'token_hash' => $tokenHash,
'expires_at' => date('Y-m-d H:i:s', time() + 3600), 'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null, 'used_at' => null,
]); ]);
$this->userRepository->expects($this->once())->method('findById')->with($user->getId())->willReturn($user); $this->userRepository->expects($this->once())
->method('findById')
->with($user->getId())
->willReturn($user);
$result = $this->service->validateToken($tokenRaw); $result = $this->service->validateToken($tokenRaw);
$this->assertSame($user, $result); $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 public function testValidateTokenDeletedUserReturnsNull(): void
{ {
$row = [ $tokenRaw = 'token-valide-mais-user-supprime';
$tokenHash = hash('sha256', $tokenRaw);
$this->resetRepository->expects($this->once())
->method('findActiveByHash')
->with($tokenHash)
->willReturn([
'user_id' => 999, 'user_id' => 999,
'expires_at' => date('Y-m-d H:i:s', time() + 3600), 'expires_at' => date('Y-m-d H:i:s', time() + 3600),
'used_at' => null, 'used_at' => null,
]; ]);
$this->userRepository->expects($this->once())
->method('findById')
->with(999)
->willReturn(null);
$this->resetRepository->expects($this->once())->method('findActiveByHash')->willReturn($row); $result = $this->service->validateToken($tokenRaw);
$this->userRepository->expects($this->once())->method('findById')->with(999)->willReturn(null);
$result = $this->service->validateToken('token-valide-mais-user-supprime');
$this->assertNull($result); $this->assertNull($result);
} }
@@ -238,7 +219,7 @@ final class PasswordResetServiceTest extends TestCase
$this->db->method('beginTransaction')->willReturn(true); $this->db->method('beginTransaction')->willReturn(true);
$this->db->method('inTransaction')->willReturn(true); $this->db->method('inTransaction')->willReturn(true);
$this->db->expects($this->once())->method('rollBack'); $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->expectException(InvalidResetTokenException::class);
$this->expectExceptionMessageMatches('/invalide ou a expiré/'); $this->expectExceptionMessageMatches('/invalide ou a expiré/');
@@ -246,31 +227,13 @@ final class PasswordResetServiceTest extends TestCase
$this->service->resetPassword('tokeninvalide', 'nouveaumdp1'); $this->service->resetPassword('tokeninvalide', 'nouveaumdp1');
} }
/**
* resetPassword() avec un mot de passe trop court doit lever WeakPasswordException.
*/
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void 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->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 public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
{ {
$user = $this->makeUser(); $user = $this->makeUser();
@@ -290,7 +253,10 @@ final class PasswordResetServiceTest extends TestCase
'expires_at' => date('Y-m-d H:i:s', time() + 3600), '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()) $this->userRepository->expects($this->once())
->method('updatePassword') ->method('updatePassword')
@@ -299,12 +265,6 @@ final class PasswordResetServiceTest extends TestCase
$this->service->resetPassword($tokenRaw, 'nouveaumdp1'); $this->service->resetPassword($tokenRaw, 'nouveaumdp1');
} }
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un utilisateur de test standard.
*/
private function makeUser(): User private function makeUser(): User
{ {
return new User( return new User(

View File

@@ -133,7 +133,7 @@ final class CategoryRepositoryTest extends TestCase
public function testFindByIdReturnsNullWhenMissing(): void public function testFindByIdReturnsNullWhenMissing(): void
{ {
$stmt = $this->stmtForRead(row: false); $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)); $this->assertNull($this->repository->findById(99));
} }

View File

@@ -202,35 +202,7 @@ final class MediaControllerTest extends ControllerTestCase
$this->assertStatus($res, 200); $this->assertStatus($res, 200);
$this->assertJsonContentType($res); $this->assertJsonContentType($res);
$this->assertJsonContains($res, [ $this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp']);
'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',
]);
} }
// ── delete ─────────────────────────────────────────────────────── // ── 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 public function testFindByUserIdReturnsEmptyArrayWhenNone(): void
{ {
$stmt = $this->stmtForRead([]); $stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt); $this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99)); $this->assertSame([], $this->repository->findByUserId(99));
} }

View File

@@ -211,7 +211,7 @@ final class MediaServiceTest extends TestCase
private function makeUploadedFile(int $size): UploadedFileInterface private function makeUploadedFile(int $size): UploadedFileInterface
{ {
$stream = $this->createMock(StreamInterface::class); $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 = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size); $file->method('getSize')->willReturn($size);
@@ -227,7 +227,7 @@ final class MediaServiceTest extends TestCase
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{ {
$stream = $this->createMock(StreamInterface::class); $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 = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size); $file->method('getSize')->willReturn($size);

View File

@@ -59,21 +59,6 @@ final class PostExtensionTest extends TestCase
self::assertNull($this->call('post_thumbnail', $post)); 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 public function testPostInitialsUseMeaningfulWordsAndFallback(): void
{ {
$post = new Post(1, 'Article de Blog', '<p>Contenu</p>', 'slug'); $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 public function testFindRecentReturnsEmptyArray(): void
{ {
$stmt = $this->stmtForRead([]); $stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt); $this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findRecent(5)); $this->assertSame([], $this->repository->findRecent(5));
} }
@@ -170,7 +170,7 @@ final class PostRepositoryTest extends TestCase
public function testFindRecentPassesLimitCorrectly(): void public function testFindRecentPassesLimitCorrectly(): void
{ {
$stmt = $this->stmtForRead([]); $stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt); $this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once()) $stmt->expects($this->once())
->method('bindValue') ->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 ───────────────────────────────────────────────── // ── 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 ──────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────
/** /**

View File

@@ -6,43 +6,43 @@ namespace Tests\Shared;
use App\Shared\Bootstrap; use App\Shared\Bootstrap;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use ReflectionProperty;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
use Slim\App;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class BootstrapTest extends TestCase final class BootstrapTest extends TestCase
{ {
/** @var array<string, string> */
private array $envBackup = []; private array $envBackup = [];
protected function setUp(): void protected function setUp(): void
{ {
$this->envBackup = $_ENV; $this->envBackup = [
'APP_AUTO_PROVISION' => $_ENV['APP_AUTO_PROVISION'] ?? null,
];
} }
protected function tearDown(): void 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(); $bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface { $container = $this->createStub(ContainerInterface::class);
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
public function has(string $id): bool $this->setPrivate($bootstrap, 'container', $container);
{
return false;
}
};
$this->setPrivateProperty($bootstrap, 'container', $container);
self::assertSame($container, $bootstrap->getContainer());
self::assertSame($container, $bootstrap->initializeInfrastructure()); self::assertSame($container, $bootstrap->initializeInfrastructure());
self::assertSame($container, $bootstrap->getContainer());
} }
public function testCreateHttpAppReturnsPreloadedApp(): void public function testCreateHttpAppReturnsPreloadedApp(): void
@@ -50,40 +50,29 @@ final class BootstrapTest extends TestCase
$bootstrap = Bootstrap::create(); $bootstrap = Bootstrap::create();
$app = AppFactory::create(); $app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'app', $app); $this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->createHttpApp()); self::assertSame($app, $bootstrap->createHttpApp());
} }
public function testInitializeReturnsPreloadedAppWhenAutoProvisioningDisabled(): void public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void
{ {
$_ENV['APP_ENV'] = 'production';
$_ENV['APP_AUTO_PROVISION'] = '0'; $_ENV['APP_AUTO_PROVISION'] = '0';
$bootstrap = Bootstrap::create(); $bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface { $container = $this->createStub(ContainerInterface::class);
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
public function has(string $id): bool
{
return false;
}
};
$app = AppFactory::create(); $app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'container', $container); $this->setPrivate($bootstrap, 'container', $container);
$this->setPrivateProperty($bootstrap, 'app', $app); $this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->initialize()); 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->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; use Slim\Psr7\Factory\ServerRequestFactory;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ClientIpResolverTest extends TestCase final class ClientIpResolverTest extends TestCase
{ {
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
@@ -53,27 +54,4 @@ final class ClientIpResolverTest extends TestCase
self::assertSame('127.0.0.1', $resolver->resolve($request)); 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; use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ConfigTest extends TestCase final class ConfigTest extends TestCase
{ {
public function testGetTwigCacheReturnsFalseInDev(): void public function testGetTwigCacheReturnsFalseInDev(): void
@@ -22,25 +23,6 @@ final class ConfigTest extends TestCase
self::assertStringEndsWith('/var/cache/twig', $cachePath); 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 public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
{ {
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite'; $dbFile = dirname(__DIR__, 2).'/database/app.sqlite';

View File

@@ -46,21 +46,6 @@ final class ExtensionTest extends TestCase
], $extension->getGlobals()); ], $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 public function testCsrfExtensionExposesTokens(): void
{ {
$storage = []; $storage = [];

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class FlashServiceTest extends TestCase final class FlashServiceTest extends TestCase
{ {
protected function setUp(): void protected function setUp(): void
@@ -34,26 +35,6 @@ final class FlashServiceTest extends TestCase
self::assertArrayNotHasKey('count', $_SESSION['flash']); 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 public function testGetReturnsNullWhenMissing(): void
{ {
$flash = new FlashService(); $flash = new FlashService();

View File

@@ -6,17 +6,17 @@ namespace Tests\Shared;
use App\Shared\Mail\MailService; use App\Shared\Mail\MailService;
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Slim\Views\Twig; use Slim\Views\Twig;
use Twig\Loader\ArrayLoader; use Twig\Loader\ArrayLoader;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class MailServiceTest extends TestCase final class MailServiceTest extends TestCase
{ {
public function testCreateMailerUsesSslConfiguration(): void public function testCreateMailerUsesSslConfiguration(): void
{ {
$service = $this->createService('ssl', 465); $service = $this->makeService('ssl', 465);
/** @var PHPMailer $mailer */
$mailer = $this->invokeCreateMailer($service); $mailer = $this->invokeCreateMailer($service);
self::assertSame('smtp', $mailer->Mailer); self::assertSame('smtp', $mailer->Mailer);
@@ -27,25 +27,23 @@ final class MailServiceTest extends TestCase
self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure); self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure);
self::assertSame(465, $mailer->Port); self::assertSame(465, $mailer->Port);
self::assertSame(PHPMailer::CHARSET_UTF8, $mailer->CharSet); 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); self::assertSame('Slim Blog', $mailer->FromName);
} }
public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void
{ {
$service = $this->createService('tls', 587); $service = $this->makeService('tls', 587);
/** @var PHPMailer $mailer */
$mailer = $this->invokeCreateMailer($service); $mailer = $this->invokeCreateMailer($service);
self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure); self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure);
self::assertSame(587, $mailer->Port); 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([ $twig = new Twig(new ArrayLoader([
'emails/test.twig' => '<p>Hello {{ name }}</p>', 'emails/test.twig' => '<p>Bonjour {{ name }}</p>',
])); ]));
return new MailService( return new MailService(
@@ -55,16 +53,19 @@ final class MailServiceTest extends TestCase
'mailer-user', 'mailer-user',
'mailer-pass', 'mailer-pass',
$encryption, $encryption,
'noreply@example.test', 'no-reply@example.test',
'Slim Blog', '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); $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 App\Shared\Exception\NotFoundException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class NotFoundExceptionTest extends TestCase 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,14 +7,13 @@ use App\Shared\Database\Provisioner;
use PDO; use PDO;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ProvisionerTest extends TestCase final class ProvisionerTest extends TestCase
{ {
private PDO $db; private PDO $db;
private string $lockPath; private string $lockPath;
private bool $lockExistedBefore; private array $envBackup = [];
/** @var array<string, string> */
private array $envBackup;
protected function setUp(): void protected function setUp(): void
{ {
@@ -25,51 +24,53 @@ final class ProvisionerTest extends TestCase
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1); $this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
$this->lockPath = dirname(__DIR__, 2) . '/database/.provision.lock'; $this->lockPath = dirname(__DIR__, 2) . '/database/.provision.lock';
$this->lockExistedBefore = file_exists($this->lockPath); @unlink($this->lockPath);
$this->envBackup = [ $this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '', 'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? null,
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '', 'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? null,
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '', 'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? null,
]; ];
$_ENV['ADMIN_USERNAME'] = 'shared-admin'; $_ENV['ADMIN_USERNAME'] = 'Admin';
$_ENV['ADMIN_EMAIL'] = 'shared-admin@example.com'; $_ENV['ADMIN_EMAIL'] = 'Admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'strong-secret'; $_ENV['ADMIN_PASSWORD'] = 'secret1234';
} }
protected function tearDown(): void protected function tearDown(): void
{ {
@unlink($this->lockPath);
foreach ($this->envBackup as $key => $value) { foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value; $_ENV[$key] = $value;
} }
if (!$this->lockExistedBefore && file_exists($this->lockPath)) {
@unlink($this->lockPath);
} }
} }
public function testRunAppliesMigrationsSeedsAdminAndCreatesLockFile(): void public function testRunCreatesProvisionLockAndSeedsAdminUser(): void
{ {
Provisioner::run($this->db); Provisioner::run($this->db);
self::assertFileExists($this->lockPath); self::assertFileExists($this->lockPath);
$migrationCount = (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn(); $row = $this->db->query('SELECT username, email, role FROM users')->fetch();
self::assertGreaterThan(0, $migrationCount, 'Les migrations doivent être enregistrées');
$admin = $this->db->query("SELECT username, email, role FROM users WHERE username = 'shared-admin'")->fetch(); self::assertIsArray($row);
self::assertIsArray($admin); self::assertSame('admin', $row['username']);
self::assertSame('shared-admin@example.com', $admin['email']); self::assertSame('admin@example.com', $row['email']);
self::assertSame('admin', $admin['role']); self::assertSame('admin', $row['role']);
} }
public function testRunIsIdempotentForAdminSeed(): void public function testRunIsIdempotent(): void
{ {
Provisioner::run($this->db); Provisioner::run($this->db);
Provisioner::run($this->db); Provisioner::run($this->db);
$adminCount = (int) $this->db->query("SELECT COUNT(*) FROM users WHERE username = 'shared-admin'")->fetchColumn(); $count = (int) $this->db->query('SELECT COUNT(*) FROM users WHERE username = "admin"')->fetchColumn();
self::assertSame(1, $adminCount);
self::assertSame(1, $count);
} }
} }

View File

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

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class SessionManagerEdgeCasesTest extends TestCase final class SessionManagerEdgeCasesTest extends TestCase
{ {
private SessionManager $manager; private SessionManager $manager;
@@ -30,14 +31,6 @@ final class SessionManagerEdgeCasesTest extends TestCase
self::assertFalse($this->manager->isAuthenticated()); 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 public function testSetUserUsesDefaultRoleUser(): void
{ {
$this->manager->setUser(12, 'julien'); $this->manager->setUser(12, 'julien');

View File

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

View File

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