first commit
This commit is contained in:
401
tests/Media/MediaControllerTest.php
Normal file
401
tests/Media/MediaControllerTest.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use Netig\Netslim\Media\Application\MediaServiceInterface;
|
||||
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\Exception\StorageException;
|
||||
use Netig\Netslim\Media\UI\Http\MediaController;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @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('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Media/admin/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('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Media/admin/index.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$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('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findPaginated');
|
||||
|
||||
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* index() doit exposer les résumés d'usage typés par identifiant de média.
|
||||
*/
|
||||
public function testIndexPassesMediaUsageSummaryToView(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
|
||||
$usageSummary = [
|
||||
'count' => 1,
|
||||
'references' => [new MediaUsageReference(8, 'Contenu lié', '/admin/content/edit/8')],
|
||||
];
|
||||
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('findPaginated')
|
||||
->with(1, 12)
|
||||
->willReturn(new PaginatedResult([$media], 1, 1, 12));
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('getUsageSummaries')
|
||||
->with([$media], 5)
|
||||
->willReturn([$media->getId() => $usageSummary]);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with(
|
||||
$this->anything(),
|
||||
'@Media/admin/index.twig',
|
||||
$this->callback(static function (array $data) use ($media, $usageSummary): bool {
|
||||
return isset($data['mediaUsage'][$media->getId()])
|
||||
&& $data['mediaUsage'][$media->getId()] === $usageSummary;
|
||||
}),
|
||||
)
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── 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(new Media(12, 'abc123.webp', '/media/abc123.webp', 'hash', 1));
|
||||
|
||||
$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', 'mediaId' => 12]);
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit bloquer la suppression si le média est encore référencé et lister les titres connus.
|
||||
*/
|
||||
public function testDeleteRedirectsWithDetailedErrorWhenMediaIsStillReferenced(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
|
||||
$usageSummary = [
|
||||
'count' => 2,
|
||||
'references' => [
|
||||
new MediaUsageReference(8, 'Contenu A', '/admin/content/edit/8'),
|
||||
new MediaUsageReference(9, 'Contenu B', '/admin/content/edit/9'),
|
||||
],
|
||||
];
|
||||
|
||||
$this->mediaService->method('findById')->willReturn($media);
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('getUsageSummary')
|
||||
->with($media, 3)
|
||||
->willReturn($usageSummary);
|
||||
$this->mediaService->expects($this->never())->method('delete');
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(42);
|
||||
|
||||
$this->flash->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
'media_error',
|
||||
$this->callback(static fn (string $message): bool => str_contains($message, '2 contenu(s)')
|
||||
&& str_contains($message, '« Contenu A »')
|
||||
&& str_contains($message, '« Contenu B »')),
|
||||
);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
56
tests/Media/MediaModelTest.php
Normal file
56
tests/Media/MediaModelTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use DateTime;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
335
tests/Media/MediaRepositoryTest.php
Normal file
335
tests/Media/MediaRepositoryTest.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Infrastructure\PdoMediaRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoMediaRepository.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoMediaRepository $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 PdoMediaRepository($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): MockObject&PDOStatement
|
||||
{
|
||||
$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): MockObject&PDOStatement
|
||||
{
|
||||
$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 bien la table `media`.
|
||||
*/
|
||||
public function testFindAllRequestsMediaQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('query')
|
||||
->with($this->stringContains('FROM media'))
|
||||
->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->expects($this->once())->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($this->callback(fn (array $params): bool => in_array(5, $params, true)));
|
||||
|
||||
$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($this->callback(fn (array $params): bool => in_array(8, $params, true)));
|
||||
|
||||
$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($this->callback(fn (array $params): bool => in_array($hash, $params, true)));
|
||||
|
||||
$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->expects($this->once())->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($this->callback(fn (array $params): bool => in_array(4, $params, true)));
|
||||
|
||||
$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));
|
||||
}
|
||||
}
|
||||
80
tests/Media/MediaSchemaIntegrationTest.php
Normal file
80
tests/Media/MediaSchemaIntegrationTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MediaSchemaIntegrationTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
|
||||
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||
Migrator::run($this->db);
|
||||
|
||||
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user')");
|
||||
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'bob', 'bob@example.com', 'hash', 'user')");
|
||||
}
|
||||
|
||||
public function testMediaHashIsUniquePerUser(): void
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'first.webp',
|
||||
':url' => '/media/first.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:00:00',
|
||||
]);
|
||||
|
||||
$this->expectException(PDOException::class);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'second.webp',
|
||||
':url' => '/media/second.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:01:00',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMediaHashCanBeSharedAcrossDifferentUsers(): void
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'alice.webp',
|
||||
':url' => '/media/alice.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:00:00',
|
||||
]);
|
||||
$stmt->execute([
|
||||
':filename' => 'bob.webp',
|
||||
':url' => '/media/bob.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 2,
|
||||
':created_at' => '2026-03-19 10:01:00',
|
||||
]);
|
||||
|
||||
$count = (int) $this->db->query("SELECT COUNT(*) FROM media WHERE hash = 'same-hash'")->fetchColumn();
|
||||
|
||||
self::assertSame(2, $count);
|
||||
}
|
||||
}
|
||||
103
tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php
Normal file
103
tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?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\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceDuplicateAfterInsertRaceTest 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_race_' . uniqid('', true);
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$storage = new LocalMediaStorage($this->uploadDir);
|
||||
|
||||
$this->service = new MediaApplicationService(
|
||||
$this->repository,
|
||||
$this->mediaUsageReader,
|
||||
new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024),
|
||||
new DeleteMedia($this->repository, $storage),
|
||||
);
|
||||
}
|
||||
|
||||
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('findByHashForUser')
|
||||
->with($hash, 1)
|
||||
->willReturnOnConsecutiveCalls(null, $duplicate);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('create')
|
||||
->willThrowException(new PDOException('duplicate key'));
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
self::assertSame('/media/existing.gif', $media->getUrl());
|
||||
self::assertSame(77, $media->getId());
|
||||
self::assertCount(0, glob($this->uploadDir . '/*') ?: []);
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
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(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;
|
||||
}
|
||||
}
|
||||
51
tests/Media/MediaServiceEdgeCasesTest.php
Normal file
51
tests/Media/MediaServiceEdgeCasesTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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\Domain\Exception\FileTooLargeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\StorageException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceEdgeCasesTest extends TestCase
|
||||
{
|
||||
public function testRejectsWhenSizeUnknown(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(null);
|
||||
|
||||
$storage = new LocalMediaStorage('/tmp');
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 1000), new DeleteMedia($repo, $storage));
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$service->store($file, 1);
|
||||
}
|
||||
|
||||
public function testRejectsWhenFileTooLarge(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(999999);
|
||||
$file->method('getTemporaryPath')->willReturn('/tmp/file');
|
||||
|
||||
$storage = new LocalMediaStorage('/tmp');
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 100), new DeleteMedia($repo, $storage));
|
||||
|
||||
$this->expectException(FileTooLargeException::class);
|
||||
$service->store($file, 1);
|
||||
}
|
||||
}
|
||||
43
tests/Media/MediaServiceInvalidMimeTest.php
Normal file
43
tests/Media/MediaServiceInvalidMimeTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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\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\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceInvalidMimeTest extends TestCase
|
||||
{
|
||||
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
self::assertNotFalse($tmpFile);
|
||||
file_put_contents($tmpFile, 'not an image');
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(filesize($tmpFile));
|
||||
$file->method('getTemporaryPath')->willReturn($tmpFile);
|
||||
|
||||
$storage = new LocalMediaStorage(sys_get_temp_dir());
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 500000), new DeleteMedia($repo, $storage));
|
||||
|
||||
try {
|
||||
$this->expectException(InvalidMimeTypeException::class);
|
||||
$service->store($file, 1);
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tests/Media/MediaServiceInvalidTempPathTest.php
Normal file
39
tests/Media/MediaServiceInvalidTempPathTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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\Domain\Exception\StorageException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MediaServiceInvalidTempPathTest extends TestCase
|
||||
{
|
||||
public function testRejectsWhenTemporaryPathIsMissing(): void
|
||||
{
|
||||
$repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(128);
|
||||
$file->method('getTemporaryPath')->willReturn(null);
|
||||
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$storage = new LocalMediaStorage(sys_get_temp_dir());
|
||||
$service = new MediaApplicationService($repository, $mediaUsageReader, new StoreMedia($repository, $storage, '/media', 500000), new DeleteMedia($repository, $storage));
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');
|
||||
|
||||
$service->store($file, 1);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
20
tests/Media/MediaUsageReferenceTest.php
Normal file
20
tests/Media/MediaUsageReferenceTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MediaUsageReferenceTest extends TestCase
|
||||
{
|
||||
public function testExposesImmutableUsageReferenceData(): void
|
||||
{
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
self::assertSame(12, $reference->getId());
|
||||
self::assertSame('Contenu lié', $reference->getTitle());
|
||||
self::assertSame('/admin/content/edit/12', $reference->getEditPath());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user