first commit
This commit is contained in:
348
tests/Media/MediaServiceTest.php
Normal file
348
tests/Media/MediaServiceTest.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Domain\Exception\FileTooLargeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\InvalidMimeTypeException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour MediaApplicationService.
|
||||
*
|
||||
* Stratégie : les opérations sur le système de fichiers réel (finfo, GD,
|
||||
* copy, moveTo) sont exercées via de vrais fichiers JPEG temporaires ;
|
||||
* 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 MediaUsageReaderInterface $mediaUsageReader;
|
||||
|
||||
private string $uploadDir;
|
||||
|
||||
private MediaApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
$this->mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$storage = new LocalMediaStorage($this->uploadDir);
|
||||
|
||||
$this->service = new MediaApplicationService(
|
||||
mediaRepository: $this->repository,
|
||||
mediaUsageReader: $this->mediaUsageReader,
|
||||
storeMedia: new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024),
|
||||
deleteMedia: new DeleteMedia($this->repository, $storage),
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
$this->assertSame('/media/existing.jpg', $media->getUrl());
|
||||
$this->assertSame(7, $media->getId());
|
||||
|
||||
@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));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
$this->assertStringStartsWith('/media/', $media->getUrl());
|
||||
|
||||
// Le fichier doit exister sur le disque
|
||||
$filename = basename($media->getUrl());
|
||||
$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)->willReturn(1);
|
||||
|
||||
$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)->willReturn(1);
|
||||
|
||||
// Ne doit pas lever d'exception
|
||||
$this->service->delete($media);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit conserver le fichier si la suppression en base echoue.
|
||||
*/
|
||||
public function testDeleteKeepsFileWhenRepositoryDeleteFails(): void
|
||||
{
|
||||
$filename = 'test_media_keep.jpg';
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($filePath, 'fake image data');
|
||||
|
||||
$media = new Media(43, $filename, '/media/' . $filename, 'fakehash', 1);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('delete')
|
||||
->with(43)
|
||||
->willThrowException(new \RuntimeException('db delete failed'));
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
try {
|
||||
$this->service->delete($media);
|
||||
} finally {
|
||||
$this->assertFileExists($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() ne doit pas supprimer le fichier si la ligne base n'existe plus.
|
||||
*/
|
||||
public function testDeleteKeepsFileWhenRecordWasAlreadyMissing(): void
|
||||
{
|
||||
$filename = 'test_media_orphan.jpg';
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($filePath, 'fake image data');
|
||||
|
||||
$media = new Media(44, $filename, '/media/' . $filename, 'fakehash', 1);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('delete')
|
||||
->with(44)
|
||||
->willReturn(0);
|
||||
|
||||
$this->service->delete($media);
|
||||
|
||||
$this->assertFileExists($filePath);
|
||||
}
|
||||
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* getUsageSummary() doit déléguer au port de lecture des usages média.
|
||||
*/
|
||||
public function testGetUsageSummaryDelegatesToMediaUsageReader(): void
|
||||
{
|
||||
$media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
$this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3])->willReturn([3 => 2]);
|
||||
$this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3], 3)->willReturn([3 => [$reference]]);
|
||||
|
||||
$summary = $this->service->getUsageSummary($media, 3);
|
||||
|
||||
$this->assertSame(2, $summary['count']);
|
||||
$this->assertSame([$reference], $summary['references']);
|
||||
}
|
||||
|
||||
public function testGetUsageSummariesDelegatesToMediaUsageReaderInBatch(): void
|
||||
{
|
||||
$mediaA = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
|
||||
$mediaB = new Media(8, 'banner.jpg', '/media/banner.jpg', 'def456', 1);
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
$this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3, 8])->willReturn([
|
||||
3 => 2,
|
||||
8 => 0,
|
||||
]);
|
||||
$this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3, 8], 4)->willReturn([
|
||||
3 => [$reference],
|
||||
]);
|
||||
|
||||
$summaries = $this->service->getUsageSummaries([$mediaA, $mediaB], 4);
|
||||
|
||||
$this->assertSame(2, $summaries[3]['count']);
|
||||
$this->assertSame([$reference], $summaries[3]['references']);
|
||||
$this->assertSame(0, $summaries[8]['count']);
|
||||
$this->assertSame([], $summaries[8]['references']);
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un mock d'UploadedFile avec une taille donnée et un tmpPath bidon.
|
||||
*/
|
||||
private function makeUploadedFile(int $size): UploadedMediaInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn($size);
|
||||
$file->method('getTemporaryPath')->willReturn('/nonexistent/path');
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un mock d'UploadedFile pointant vers un fichier réel.
|
||||
*/
|
||||
private function makeUploadedFileFromPath(string $path, int $size): UploadedMediaInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn($size);
|
||||
$file->method('getTemporaryPath')->willReturn($path);
|
||||
$file->method('moveTo')->willReturnCallback(function (string $dest) use ($path): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user