diff --git a/backend/tests/Fakes/FakeFileUploader.php b/backend/tests/Fakes/FakeFileUploader.php new file mode 100644 index 0000000..61ab381 --- /dev/null +++ b/backend/tests/Fakes/FakeFileUploader.php @@ -0,0 +1,26 @@ + + */ + public array $uploads = []; + + public function upload(FileToUpload $file, string $folder): string + { + $this->uploads[] = ['file' => $file, 'folder' => $folder]; + + return $folder . '/fake-' . $file->getOriginalName(); + } + + public function url(string $path): string + { + return 'https://test.local/storage/' . $path; + } +} diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 2ff9089..52662f8 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -14,6 +14,8 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class ElementsEndpointTest extends TestCase @@ -189,6 +191,84 @@ class ElementsEndpointTest extends TestCase ); } + public function testIconImageUploadRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->post( + "/api/elements/{$element->getId()}/icon-image", + ['iconImage' => $this->iconImageUpload()], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedIconImageUploadReturnsElementPayload(): void + { + Storage::fake('public'); + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->post( + "/api/elements/{$element->getId()}/icon-image", + ['iconImage' => $this->iconImageUpload()], + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertStringContainsString( + '/storage/element-icons/', + $body['element']['iconImageUrl'], + ); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertNotNull($updatedElement); + $updatedIconImageUrl = $updatedElement->getIconImageUrl(); + $this->assertIsString($updatedIconImageUrl); + $this->assertStringStartsWith( + 'element-icons/', + $updatedIconImageUrl, + ); + Storage::disk('public')->assertExists($updatedIconImageUrl); + } + private function createSession(string $token): void { $userRepository = app(UserRepository::class); @@ -211,4 +291,19 @@ class ElementsEndpointTest extends TestCase ), )); } + + private function iconImageUpload(): UploadedFile + { + $fixturePath = __DIR__ + . '/../../../frontend/rabbi_gerzi/public/assets/' + . 'baderech-haavodah-icon.png'; + + return new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 99bfa3e..984dae0 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -8,13 +8,17 @@ use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateElementIconImage; use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; use App\Element\UseCases\UpdateElement\UpdatePdfPath; use App\Element\UseCases\UpdateElement\UpdateRichText; use App\Element\UseCases\UpdateElement\UpdateTitle; use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl; use App\Set\Set as DomainSet; +use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Tests\Fakes\FakeElementRepository; +use Tests\Fakes\FakeFileUploader; use Tests\TestCase; class ElementControllerTest extends TestCase @@ -23,9 +27,12 @@ class ElementControllerTest extends TestCase private FakeElementRepository $elementRepo; + private FakeFileUploader $fileUploader; + protected function setUp(): void { $this->elementRepo = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); $getElement = new GetElement($this->elementRepo); $updateElement = new UpdateElement( new UpdateTitle($this->elementRepo), @@ -36,7 +43,15 @@ class ElementControllerTest extends TestCase new UpdateYoutubeUrl($this->elementRepo), $this->elementRepo, ); - $this->controller = new ElementController($getElement, $updateElement); + $this->controller = new ElementController( + $getElement, + $updateElement, + new UpdateElementIconImage( + $this->elementRepo, + $this->fileUploader, + ), + $this->fileUploader, + ); } public function testShowReturnsElementPayload(): void @@ -135,6 +150,50 @@ class ElementControllerTest extends TestCase ); } + public function testUploadIconImageReturnsElementPayload(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + null, + null, + null, + ); + $fixturePath = __DIR__ + . '/../../../../frontend/rabbi_gerzi/public/assets/' + . 'baderech-haavodah-icon.png'; + $request = new Request([], [], [], [], [ + 'iconImage' => new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ), + ]); + + $response = $this->controller->uploadIconImage( + $element->getId(), + $request, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertSame( + 'https://test.local/storage/element-icons/fake-icon.png', + $body['element']['iconImageUrl'], + ); + $this->assertSame( + 'element-icons', + $this->fileUploader->uploads[0]['folder'], + ); + } + private function createSet(int $id, string $name): DomainSet { return new DomainSet( diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php new file mode 100644 index 0000000..14328f8 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php @@ -0,0 +1,175 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->useCase = new UpdateElementIconImage( + $this->elementRepository, + $this->fileUploader, + ); + } + + /** + * @return array{ + * iconImageContents: string, + * iconImageOriginalName: string, + * iconImageMimeType: string, + * iconImageSizeBytes: int + * } + */ + private function imageArgs(): array + { + return [ + 'iconImageContents' => 'binary-image-bytes', + 'iconImageOriginalName' => 'icon.png', + 'iconImageMimeType' => 'image/png', + 'iconImageSizeBytes' => 1024, + ]; + } + + public function testUploadsIconImageAndStoresPath(): void + { + $element = $this->createElement(); + + $updatedElement = $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: $element->getId(), + ) + ); + + $this->assertSame( + 'element-icons/fake-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + 'element-icons/fake-icon.png', + $this->elementRepository + ->find($element->getId()) + ->getIconImageUrl(), + ); + $this->assertSame( + 'element-icons', + $this->fileUploader->uploads[0]['folder'], + ); + } + + public function testNullIdThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: null, + ) + ); + } + + public function testNullIconImageThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image is required'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + ) + ); + } + + public function testDisallowedMimeThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'icon image must be a jpeg, png or webp image', + ); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: 'not-an-image', + iconImageOriginalName: 'icon.pdf', + iconImageMimeType: 'application/pdf', + iconImageSizeBytes: 1024, + ) + ); + } + + public function testOversizedIconImageThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image must be 5MB or smaller'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: 'big', + iconImageOriginalName: 'icon.png', + iconImageMimeType: 'image/png', + iconImageSizeBytes: 6 * 1024 * 1024, + ) + ); + } + + public function testMissingElementThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: 999, + ) + ); + } + + private function createElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: 'Original title', + description: 'Original description', + iconImageUrl: null, + richText: '', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + } +}