Files
netslim-core/tests/Media/MediaServiceTest.php
2026-03-20 22:13:41 +01:00

349 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}