first commit

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

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use App\Media\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;
/**
* Tests unitaires pour MediaController.
*
* Couvre index(), upload() et delete() :
* - index : filtrage admin vs utilisateur ordinaire
* - upload : absence de fichier, erreur PSR-7, exceptions métier (taille, MIME, stockage), succès
* - delete : introuvable, non-propriétaire, succès propriétaire, succès admin
*/
final class MediaControllerTest 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,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit appeler findAll() pour un admin et rendre la vue.
*/
public function testIndexShowsAllMediaForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
$this->mediaService->expects($this->never())->method('findByUserId');
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/media/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler findAll() pour un éditeur.
*/
public function testIndexShowsAllMediaForEditor(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(true);
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
$this->mediaService->expects($this->never())->method('findByUserId');
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler findByUserId() pour un utilisateur ordinaire.
*/
public function testIndexShowsOwnMediaForRegularUser(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(42);
$this->mediaService->expects($this->once())->method('findByUserId')->with(42)->willReturn([]);
$this->mediaService->expects($this->never())->method('findAll');
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
}
// ── upload ───────────────────────────────────────────────────────
/**
* upload() doit retourner 400 JSON si aucun fichier n'est dans la requête.
*/
public function testUploadReturns400WhenNoFilePresent(): void
{
$req = $this->makePost('/admin/media/upload');
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 400);
$this->assertJsonContentType($res);
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
}
/**
* upload() doit retourner 400 JSON si le fichier PSR-7 signale une erreur d'upload.
*/
public function testUploadReturns400WhenFileHasUploadError(): void
{
/** @var UploadedFileInterface&MockObject $file */
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getError')->willReturn(UPLOAD_ERR_INI_SIZE);
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 400);
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
}
/**
* upload() doit retourner 413 JSON si le fichier dépasse la taille autorisée.
*/
public function testUploadReturns413OnFileTooLarge(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new FileTooLargeException(2 * 1024 * 1024));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 413);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 415 JSON si le type MIME n'est pas autorisé.
*/
public function testUploadReturns415OnInvalidMimeType(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new InvalidMimeTypeException('application/pdf'));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 415);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 500 JSON si une erreur de stockage survient.
*/
public function testUploadReturns500OnStorageException(): void
{
$file = $this->makeValidUploadedFile();
$this->mediaService->method('store')
->willThrowException(new StorageException('Disk full'));
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 500);
$this->assertJsonContentType($res);
}
/**
* upload() doit retourner 200 JSON avec l'URL du fichier en cas de succès.
*/
public function testUploadReturns200JsonWithUrlOnSuccess(): void
{
$file = $this->makeValidUploadedFile();
$this->sessionManager->method('getUserId')->willReturn(1);
$this->mediaService->method('store')->willReturn('/media/abc123.webp');
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
$res = $this->controller->upload($req, $this->makeResponse());
$this->assertStatus($res, 200);
$this->assertJsonContentType($res);
$this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp']);
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit flasher une erreur et rediriger si le média est introuvable.
*/
public function testDeleteRedirectsWithErrorWhenMediaNotFound(): void
{
$this->mediaService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('media_error', 'Fichier introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit flasher une erreur si l'utilisateur n'est pas propriétaire du média.
*/
public function testDeleteRedirectsWithErrorWhenNotOwner(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(99); // autre utilisateur
$this->flash->expects($this->once())->method('set')
->with('media_error', $this->stringContains('autorisé'));
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit supprimer le média et rediriger avec succès si l'utilisateur est propriétaire.
*/
public function testDeleteSucceedsForOwner(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(42);
$this->mediaService->expects($this->once())->method('delete')->with($media);
$this->flash->expects($this->once())->method('set')
->with('media_success', 'Fichier supprimé');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
/**
* delete() doit permettre la suppression à un admin même s'il n'est pas propriétaire.
*/
public function testDeleteSucceedsForAdmin(): void
{
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
$this->mediaService->method('findById')->willReturn($media);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(1); // admin, pas propriétaire
$this->mediaService->expects($this->once())->method('delete');
$res = $this->controller->delete(
$this->makePost('/admin/media/delete/5'),
$this->makeResponse(),
['id' => '5'],
);
$this->assertRedirectTo($res, '/admin/media');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée un mock d'UploadedFileInterface sans erreur d'upload.
*
* @return UploadedFileInterface&MockObject
*/
private function makeValidUploadedFile(): UploadedFileInterface
{
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
return $file;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use DateTime;
use PHPUnit\Framework\TestCase;
final class MediaModelTest extends TestCase
{
public function testConstructAndGettersExposeMediaData(): void
{
$createdAt = new DateTime('2026-03-01 10:00:00');
$media = new Media(8, 'file.webp', '/media/file.webp', 'abc123', 12, $createdAt);
self::assertSame(8, $media->getId());
self::assertSame('file.webp', $media->getFilename());
self::assertSame('/media/file.webp', $media->getUrl());
self::assertSame('abc123', $media->getHash());
self::assertSame(12, $media->getUserId());
self::assertSame($createdAt, $media->getCreatedAt());
}
public function testFromArrayHydratesMedia(): void
{
$media = Media::fromArray([
'id' => '9',
'filename' => 'stored.webp',
'url' => '/media/stored.webp',
'hash' => 'hash-9',
'user_id' => '3',
'created_at' => '2026-03-02 12:30:00',
]);
self::assertSame(9, $media->getId());
self::assertSame('stored.webp', $media->getFilename());
self::assertSame('/media/stored.webp', $media->getUrl());
self::assertSame('hash-9', $media->getHash());
self::assertSame(3, $media->getUserId());
self::assertSame('2026-03-02 12:30:00', $media->getCreatedAt()->format('Y-m-d H:i:s'));
}
public function testCreatedAtDefaultsToNowWhenMissing(): void
{
$before = new DateTime('-2 seconds');
$media = new Media(1, 'f.webp', '/media/f.webp', 'h', null);
$after = new DateTime('+2 seconds');
self::assertGreaterThanOrEqual($before->getTimestamp(), $media->getCreatedAt()->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $media->getCreatedAt()->getTimestamp());
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use App\Media\MediaRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour MediaRepository.
*
* Vérifie que chaque méthode du dépôt construit le bon SQL,
* lie les bons paramètres et retourne les bonnes valeurs.
*
* PDO et PDOStatement sont mockés pour isoler complètement
* le dépôt de la base de données.
*/
final class MediaRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private MediaRepository $repository;
/**
* Données représentant une ligne média en base de données.
*
* @var array<string, mixed>
*/
private array $rowImage;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new MediaRepository($this->db);
$this->rowImage = [
'id' => 1,
'filename' => 'photo.webp',
'url' => '/media/photo.webp',
'hash' => str_repeat('a', 64),
'user_id' => 2,
'created_at' => '2024-06-01 10:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
private function stmtForWrite(int $rowCount = 1): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('rowCount')->willReturn($rowCount);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() retourne un tableau vide si aucun média n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Media hydratées.
*/
public function testFindAllReturnsMediaInstances(): void
{
$stmt = $this->stmtForRead([$this->rowImage]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Media::class, $result[0]);
$this->assertSame('photo.webp', $result[0]->getFilename());
$this->assertSame('/media/photo.webp', $result[0]->getUrl());
}
/**
* findAll() interroge la table 'media' triée par id DESC.
*/
public function testFindAllQueriesWithDescendingOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('media'),
$this->stringContains('id DESC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findByUserId ───────────────────────────────────────────────
/**
* findByUserId() retourne un tableau vide si l'utilisateur n'a aucun média.
*/
public function testFindByUserIdReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99));
}
/**
* findByUserId() retourne uniquement les médias de l'utilisateur donné.
*/
public function testFindByUserIdReturnsUserMedia(): void
{
$stmt = $this->stmtForRead([$this->rowImage]);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findByUserId(2);
$this->assertCount(1, $result);
$this->assertSame(2, $result[0]->getUserId());
}
/**
* findByUserId() exécute avec le bon user_id.
*/
public function testFindByUserIdQueriesWithCorrectUserId(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':user_id' => 5]);
$this->repository->findByUserId(5);
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si le média est absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() retourne une instance Media si le média existe.
*/
public function testFindByIdReturnsMediaWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowImage);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Media::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() exécute avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 8]);
$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([':hash' => $hash]);
$this->repository->findByHash($hash);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec les bonnes colonnes.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$media = Media::fromArray($this->rowImage);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO media'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($media): bool {
return $data[':filename'] === $media->getFilename()
&& $data[':url'] === $media->getUrl()
&& $data[':hash'] === $media->getHash()
&& $data[':user_id'] === $media->getUserId()
&& isset($data[':created_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($media);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$media = Media::fromArray($this->rowImage);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('15');
$this->assertSame(15, $this->repository->create($media));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() prépare un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM media'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 4]);
$this->repository->delete(4);
}
/**
* delete() retourne le nombre de lignes supprimées.
*/
public function testDeleteReturnsDeletedRowCount(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->delete(4));
}
/**
* delete() retourne 0 si le média n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
{
/** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository;
private string $uploadDir;
private MediaService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService($this->repository, $this->uploadDir, '/media', 5 * 1024 * 1024);
}
protected function tearDown(): void
{
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->uploadDir);
}
public function testReturnsDuplicateUrlWhenInsertRaceOccurs(): void
{
$tmpFile = $this->createMinimalGif();
$hash = hash_file('sha256', $tmpFile);
self::assertNotFalse($hash);
$duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1);
$this->repository->expects($this->exactly(2))
->method('findByHash')
->with($hash)
->willReturnOnConsecutiveCalls(null, $duplicate);
$this->repository->expects($this->once())
->method('create')
->willThrowException(new PDOException('duplicate key'));
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
self::assertSame('/media/existing.gif', $url);
self::assertCount(0, glob($this->uploadDir . '/*') ?: []);
@unlink($tmpFile);
}
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($path);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getStream')->willReturn($stream);
$file->method('moveTo')->willReturnCallback(static function (string $dest) use ($path): void {
copy($path, $dest);
});
return $file;
}
private function createMinimalGif(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_gif_');
self::assertNotFalse($tmpFile);
file_put_contents($tmpFile, base64_decode('R0lGODdhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='));
return $tmpFile;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\MediaService;
use App\Media\MediaRepositoryInterface;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\StorageException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface;
final class MediaServiceEdgeCasesTest extends TestCase
{
public function testRejectsWhenSizeUnknown(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(null);
$service = new MediaService($repo, '/tmp', '/media', 1000);
$this->expectException(StorageException::class);
$service->store($file, 1);
}
public function testRejectsWhenFileTooLarge(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->willReturn('/tmp/file');
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(999999);
$file->method('getStream')->willReturn($stream);
$service = new MediaService($repo, '/tmp', '/media', 100);
$this->expectException(FileTooLargeException::class);
$service->store($file, 1);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\StreamInterface;
final class MediaServiceInvalidMimeTest extends TestCase
{
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
{
$repo = $this->createMock(MediaRepositoryInterface::class);
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
self::assertNotFalse($tmpFile);
file_put_contents($tmpFile, 'not an image');
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($tmpFile);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(filesize($tmpFile));
$file->method('getStream')->willReturn($stream);
$file->method('getClientFilename')->willReturn('photo.png');
$service = new MediaService($repo, sys_get_temp_dir(), '/media', 500000);
try {
$this->expectException(InvalidMimeTypeException::class);
$service->store($file, 1);
} finally {
@unlink($tmpFile);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\StorageException;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
final class MediaServiceInvalidTempPathTest extends TestCase
{
public function testRejectsWhenTemporaryPathIsMissing(): void
{
$repository = $this->createMock(MediaRepositoryInterface::class);
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn(null);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn(128);
$file->method('getStream')->willReturn($stream);
$service = new MediaService($repository, sys_get_temp_dir(), '/media', 500000);
$this->expectException(StorageException::class);
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');
$service->store($file, 1);
}
}

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Media;
use App\Media\MediaRepositoryInterface;
use App\Media\MediaService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Tests unitaires pour MediaService.
*
* 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 ;
* le repository reste un mock pour isoler la logique de persistance.
*
* Les cas couverts :
* - rejet si taille > maxSize
* - rejet si type MIME non autorisé
* - déduplication : retour de l'URL existante si hash déjà connu
* - stockage : fichier écrit sur disque, media créé en base
* - suppression : fichier supprimé du disque et entrée retirée de la base
*/
final class MediaServiceTest extends TestCase
{
/** @var MediaRepositoryInterface&MockObject */
private MediaRepositoryInterface $repository;
private string $uploadDir;
private MediaService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(MediaRepositoryInterface::class);
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
@mkdir($this->uploadDir, 0755, true);
$this->service = new MediaService(
mediaRepository: $this->repository,
uploadDir: $this->uploadDir,
uploadUrl: '/media',
maxSize: 5 * 1024 * 1024,
);
}
protected function tearDown(): void
{
// Nettoyage du répertoire temporaire
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->uploadDir);
}
// ── store — rejets ─────────────────────────────────────────────
/**
* store() doit lever FileTooLargeException si la taille dépasse le maximum.
*/
public function testStoreThrowsFileTooLargeWhenOversized(): void
{
$file = $this->makeUploadedFile(size: 6 * 1024 * 1024);
$this->expectException(FileTooLargeException::class);
$this->service->store($file, 1);
}
/**
* store() doit lever InvalidMimeTypeException pour un type MIME non autorisé.
*/
public function testStoreThrowsInvalidMimeType(): void
{
// Créer un vrai fichier texte — finfo retournera text/plain
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_test_');
file_put_contents($tmpFile, 'ceci est un fichier texte');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$this->expectException(InvalidMimeTypeException::class);
try {
$this->service->store($file, 1);
} finally {
@unlink($tmpFile);
}
}
// ── store — déduplication ──────────────────────────────────────
/**
* store() doit retourner l'URL existante sans créer de doublon si le hash est connu.
*/
public function testStoreReturnsDuplicateUrl(): void
{
$tmpFile = $this->createMinimalJpeg();
$hash = hash_file('sha256', $tmpFile);
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', $hash, 1);
$this->repository->method('findByHash')->willReturn($existing);
$this->repository->expects($this->never())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
$this->assertSame('/media/existing.jpg', $url);
@unlink($tmpFile);
}
// ── store — stockage nominal ───────────────────────────────────
/**
* store() doit créer le fichier sur disque et appeler repository::create().
*/
public function testStoreWritesFileAndCreatesRecord(): void
{
$tmpFile = $this->createMinimalJpeg();
$this->repository->method('findByHash')->willReturn(null);
$this->repository->expects($this->once())->method('create');
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
$url = $this->service->store($file, 1);
$this->assertStringStartsWith('/media/', $url);
// Le fichier doit exister sur le disque
$filename = basename($url);
$this->assertFileExists($this->uploadDir . DIRECTORY_SEPARATOR . $filename);
@unlink($tmpFile);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit supprimer le fichier physique et appeler repository::delete().
*/
public function testDeleteRemovesFileAndRecord(): void
{
$filename = 'test_media.jpg';
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
file_put_contents($filePath, 'fake image data');
$media = new Media(42, $filename, '/media/' . $filename, 'fakehash', 1);
$this->repository->expects($this->once())->method('delete')->with(42);
$this->service->delete($media);
$this->assertFileDoesNotExist($filePath);
}
/**
* delete() ne doit pas lever d'exception si le fichier physique n'existe plus.
*/
public function testDeleteIsSilentWhenFileMissing(): void
{
$media = new Media(99, 'inexistant.jpg', '/media/inexistant.jpg', 'hash', 1);
$this->repository->expects($this->once())->method('delete')->with(99);
// Ne doit pas lever d'exception
$this->service->delete($media);
$this->assertTrue(true);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* findById() doit déléguer au repository et retourner null si absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(999));
}
/**
* findById() doit retourner le média trouvé.
*/
public function testFindByIdReturnsMedia(): void
{
$media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
$this->repository->method('findById')->with(3)->willReturn($media);
$this->assertSame($media, $this->service->findById(3));
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un mock d'UploadedFile avec une taille donnée et un tmpPath bidon.
*/
private function makeUploadedFile(int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn('/nonexistent/path');
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
$file->method('getStream')->willReturn($stream);
return $file;
}
/**
* Crée un mock d'UploadedFile pointant vers un fichier réel.
*/
private function makeUploadedFileFromPath(string $path, int $size): UploadedFileInterface
{
$stream = $this->createMock(StreamInterface::class);
$stream->method('getMetadata')->with('uri')->willReturn($path);
$file = $this->createMock(UploadedFileInterface::class);
$file->method('getSize')->willReturn($size);
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
$file->method('getStream')->willReturn($stream);
$file->method('moveTo')->willReturnCallback(function (string $dest) use ($path) {
copy($path, $dest);
});
return $file;
}
/**
* Crée un fichier JPEG valide via GD (1×1 pixel).
* Retourne le chemin du fichier temporaire.
*/
private function createMinimalJpeg(): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_jpeg_') . '.jpg';
$img = imagecreatetruecolor(1, 1);
imagejpeg($img, $tmpFile);
imagedestroy($img);
return $tmpFile;
}
}