267 lines
9.2 KiB
PHP
267 lines
9.2 KiB
PHP
<?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\Application\MediaApplicationService as MediaService;
|
||
use App\Media\Infrastructure\LocalMediaStorage;
|
||
use App\Post\PostRepositoryInterface;
|
||
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
|
||
*/
|
||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||
final class MediaServiceTest extends TestCase
|
||
{
|
||
/** @var MediaRepositoryInterface&MockObject */
|
||
private MediaRepositoryInterface $repository;
|
||
|
||
private PostRepositoryInterface $postRepository;
|
||
|
||
private string $uploadDir;
|
||
|
||
private MediaService $service;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||
$this->postRepository = $this->createMock(PostRepositoryInterface::class);
|
||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
|
||
@mkdir($this->uploadDir, 0755, true);
|
||
|
||
$this->service = new MediaService(
|
||
mediaRepository: $this->repository,
|
||
postRepository: $this->postRepository,
|
||
mediaStorage: new LocalMediaStorage($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();
|
||
|
||
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1);
|
||
$this->repository
|
||
->expects($this->once())
|
||
->method('findByHashForUser')
|
||
->with($this->callback(static fn (mixed $value): bool => is_string($value) && $value !== ''), 1)
|
||
->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('findByHashForUser')->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->expects($this->once())->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')->willReturnCallback(static fn (?string $key = null): mixed => $key === 'uri' ? '/nonexistent/path' : null);
|
||
|
||
$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->expects($this->once())->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;
|
||
}
|
||
}
|