349 lines
12 KiB
PHP
349 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|