diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 6a63a85..1cfc205 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -9,14 +9,17 @@ use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Shared\Files\FileUploader; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; class ElementController { public function __construct( private GetElement $getElement, private UpdateElement $updateElement, + private FileUploader $fileUploader, ) { } @@ -52,12 +55,15 @@ class ElementController ], 200); } - public function update(?int $id, Request $request): JsonResponse + public function update(Request $request): JsonResponse { + $iconImage = $this->uploadedFileInput($request, 'iconImage'); + $pdf = $this->uploadedFileInput($request, 'pdf'); + try { $element = $this->updateElement->execute( new UpdateElementRequest( - id: $id, + id: $this->intInput($request, 'elementId'), title: $this->stringInput($request, 'title'), description: $this->stringInput($request, 'description'), iconImageUrl: $this->stringInput( @@ -67,6 +73,14 @@ class ElementController richText: $this->stringInput($request, 'richText'), pdfPath: $this->stringInput($request, 'pdfPath'), youtubeUrl: $this->stringInput($request, 'youtubeUrl'), + iconImageContents: $iconImage['contents'], + iconImageOriginalName: $iconImage['originalName'], + iconImageMimeType: $iconImage['mimeType'], + iconImageSizeBytes: $iconImage['sizeBytes'], + pdfContents: $pdf['contents'], + pdfOriginalName: $pdf['originalName'], + pdfMimeType: $pdf['mimeType'], + pdfSizeBytes: $pdf['sizeBytes'], ) ); } catch (BadRequestException $exception) { @@ -84,9 +98,31 @@ class ElementController ], 200); } - private function stringInput(Request $request, string $key): ?string + private function intInput(Request $request, string $key): ?int { $value = $request->input($key); + if (is_int($value)) { + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + return null; + } + + private function stringInput(Request $request, string $key): ?string + { + if (! $request->exists($key)) { + return null; + } + + $value = $request->input($key); + if ($value === null) { + return ''; + } + if (! is_string($value)) { return null; } @@ -94,6 +130,45 @@ class ElementController return $value; } + /** + * @return array{ + * contents: string|null, + * originalName: string|null, + * mimeType: string|null, + * sizeBytes: int|null + * } + */ + private function uploadedFileInput(Request $request, string $key): array + { + $emptyFileInput = [ + 'contents' => null, + 'originalName' => null, + 'mimeType' => null, + 'sizeBytes' => null, + ]; + + if (! $request->hasFile($key)) { + return $emptyFileInput; + } + + $file = $request->file($key); + if (! $file instanceof UploadedFile) { + return $emptyFileInput; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + return $emptyFileInput; + } + + return [ + 'contents' => (string) file_get_contents($realPath), + 'originalName' => $file->getClientOriginalName(), + 'mimeType' => (string) $file->getMimeType(), + 'sizeBytes' => (int) $file->getSize(), + ]; + } + /** * @return array{ * id: int, @@ -111,10 +186,29 @@ class ElementController 'id' => $element->getId(), 'title' => $element->getTitle(), 'description' => $element->getDescription(), - 'iconImageUrl' => $element->getIconImageUrl(), + 'iconImageUrl' => $this->storageUrl( + $element->getIconImageUrl(), + 'element-icons/', + ), 'richText' => $element->getRichText(), - 'pdfPath' => $element->getPdfPath(), + 'pdfPath' => $this->storageUrl( + $element->getPdfPath(), + 'element-pdfs/', + ), 'youtubeUrl' => $element->getYoutubeUrl(), ]; } + + private function storageUrl(?string $path, string $folderPrefix): ?string + { + if ($path === null) { + return null; + } + + if (str_starts_with($path, $folderPrefix)) { + return $this->fileUploader->url($path); + } + + return $path; + } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElement.php b/backend/app/Element/UseCases/UpdateElement/UpdateElement.php index 9f8811a..0533b16 100644 --- a/backend/app/Element/UseCases/UpdateElement/UpdateElement.php +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElement.php @@ -16,6 +16,8 @@ class UpdateElement private UpdateRichText $updateRichText, private UpdatePdfPath $updatePdfPath, private UpdateYoutubeUrl $updateYoutubeUrl, + private UpdateIconImage $updateIconImage, + private UpdatePdf $updatePdf, private ElementRepository $elementRepository, ) { } @@ -35,7 +37,9 @@ class UpdateElement && $request->iconImageUrl === null && $request->richText === null && $request->pdfPath === null - && $request->youtubeUrl === null; + && $request->youtubeUrl === null + && $request->iconImageContents === null + && $request->pdfContents === null; if ($hasNoFields) { throw new BadRequestException('nothing to update'); } @@ -78,6 +82,28 @@ class UpdateElement youtubeUrl: $request->youtubeUrl, )); } + if ($request->iconImageContents !== null) { + $this->updateIconImage->execute( + new UpdateIconImageRequest( + id: $request->id, + iconImageContents: $request->iconImageContents, + iconImageOriginalName: $request->iconImageOriginalName, + iconImageMimeType: $request->iconImageMimeType, + iconImageSizeBytes: $request->iconImageSizeBytes, + ) + ); + } + if ($request->pdfContents !== null) { + $this->updatePdf->execute( + new UpdatePdfRequest( + id: $request->id, + pdfContents: $request->pdfContents, + pdfOriginalName: $request->pdfOriginalName, + pdfMimeType: $request->pdfMimeType, + pdfSizeBytes: $request->pdfSizeBytes, + ) + ); + } $element = $this->elementRepository->find($request->id); if ($element === null) { diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php index 0b5e766..aa1aa06 100644 --- a/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php @@ -12,6 +12,14 @@ class UpdateElementRequest public ?string $richText, public ?string $pdfPath, public ?string $youtubeUrl, + public ?string $iconImageContents, + public ?string $iconImageOriginalName, + public ?string $iconImageMimeType, + public ?int $iconImageSizeBytes, + public ?string $pdfContents, + public ?string $pdfOriginalName, + public ?string $pdfMimeType, + public ?int $pdfSizeBytes, ) { } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateIconImage.php b/backend/app/Element/UseCases/UpdateElement/UpdateIconImage.php new file mode 100644 index 0000000..45d274f --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateIconImage.php @@ -0,0 +1,81 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ( + $request->iconImageContents === null + || $request->iconImageOriginalName === null + || $request->iconImageMimeType === null + || $request->iconImageSizeBytes === null + ) { + throw new BadRequestException('icon image is required'); + } + + if ( + ! in_array( + $request->iconImageMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException( + 'icon image must be a jpeg, png or webp image', + ); + } + + if ($request->iconImageSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException( + 'icon image must be 5MB or smaller', + ); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $iconImage = new FileToUpload( + contents: $request->iconImageContents, + originalName: $request->iconImageOriginalName, + mimeType: $request->iconImageMimeType, + sizeBytes: $request->iconImageSizeBytes, + ); + $path = $this->fileUploader->upload($iconImage, 'element-icons'); + $element->setIconImageUrl($path); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateIconImageRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageRequest.php new file mode 100644 index 0000000..a119053 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageRequest.php @@ -0,0 +1,15 @@ +setIconImageUrl($this->nullableString( - $request->iconImageUrl, - )); + $iconImageUrl = $this->nullableString($request->iconImageUrl); + if ($iconImageUrl === null) { + $this->deleteManagedFile($element->getIconImageUrl()); + } + + $element->setIconImageUrl($iconImageUrl); return $this->elementRepository->update($element); } @@ -47,4 +53,13 @@ class UpdateIconImageUrl return $value; } + + private function deleteManagedFile(?string $path): void + { + if ($path === null) { + return; + } + + $this->fileUploader->delete($path); + } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdatePdf.php b/backend/app/Element/UseCases/UpdateElement/UpdatePdf.php new file mode 100644 index 0000000..0a13f09 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdatePdf.php @@ -0,0 +1,75 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ( + $request->pdfContents === null + || $request->pdfOriginalName === null + || $request->pdfMimeType === null + || $request->pdfSizeBytes === null + ) { + throw new BadRequestException('pdf is required'); + } + + if ( + ! in_array( + $request->pdfMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException('pdf must be a pdf file'); + } + + if ($request->pdfSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException('pdf must be 10MB or smaller'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $pdf = new FileToUpload( + contents: $request->pdfContents, + originalName: $request->pdfOriginalName, + mimeType: $request->pdfMimeType, + sizeBytes: $request->pdfSizeBytes, + ); + $path = $this->fileUploader->upload($pdf, 'element-pdfs'); + $element->setPdfPath($path); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php index d6fde1e..404c78b 100644 --- a/backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php +++ b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php @@ -6,11 +6,14 @@ use App\Element\Element; use App\Element\ElementRepository; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Shared\Files\FileUploader; class UpdatePdfPath { - public function __construct(private ElementRepository $elementRepository) - { + public function __construct( + private ElementRepository $elementRepository, + private FileUploader $fileUploader, + ) { } /** @@ -32,7 +35,12 @@ class UpdatePdfPath throw new NotFoundException('Element not found'); } - $element->setPdfPath($this->nullableString($request->pdfPath)); + $pdfPath = $this->nullableString($request->pdfPath); + if ($pdfPath === null) { + $this->deleteManagedFile($element->getPdfPath()); + } + + $element->setPdfPath($pdfPath); return $this->elementRepository->update($element); } @@ -45,4 +53,13 @@ class UpdatePdfPath return $value; } + + private function deleteManagedFile(?string $path): void + { + if ($path === null) { + return; + } + + $this->fileUploader->delete($path); + } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdatePdfRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdatePdfRequest.php new file mode 100644 index 0000000..98ac3bf --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdatePdfRequest.php @@ -0,0 +1,15 @@ +app->bind( + FileUploader::class, + LaravelFileUploader::class, + ); } } diff --git a/backend/app/Shared/Files/FileToUpload.php b/backend/app/Shared/Files/FileToUpload.php new file mode 100644 index 0000000..e8b8f41 --- /dev/null +++ b/backend/app/Shared/Files/FileToUpload.php @@ -0,0 +1,34 @@ +contents; + } + + public function getOriginalName(): string + { + return $this->originalName; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getSizeBytes(): int + { + return $this->sizeBytes; + } +} diff --git a/backend/app/Shared/Files/FileUploader.php b/backend/app/Shared/Files/FileUploader.php new file mode 100644 index 0000000..ac30916 --- /dev/null +++ b/backend/app/Shared/Files/FileUploader.php @@ -0,0 +1,12 @@ +extensionFor($file); + Storage::disk('public')->put($path, $file->getContents()); + + return $path; + } + + public function url(string $path): string + { + return Storage::disk('public')->url($path); + } + + public function delete(string $path): void + { + Storage::disk('public')->delete($path); + } + + private function extensionFor(FileToUpload $file): string + { + $mimeExtensions = [ + 'image/jpeg' => '.jpg', + 'image/png' => '.png', + 'image/webp' => '.webp', + 'application/pdf' => '.pdf', + ]; + if (isset($mimeExtensions[$file->getMimeType()])) { + return $mimeExtensions[$file->getMimeType()]; + } + + $originalExtension = pathinfo( + $file->getOriginalName(), + PATHINFO_EXTENSION, + ); + if ($originalExtension !== '') { + return '.' . $originalExtension; + } + + return ''; + } +} diff --git a/backend/database/seeders/ElementSeeder.php b/backend/database/seeders/ElementSeeder.php index ba89ab1..c7df012 100644 --- a/backend/database/seeders/ElementSeeder.php +++ b/backend/database/seeders/ElementSeeder.php @@ -6,6 +6,7 @@ use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Set\SetRepository; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Storage; use RuntimeException; class ElementSeeder extends Seeder @@ -15,11 +16,18 @@ class ElementSeeder extends Seeder $setRepository = app(SetRepository::class); $elementRepository = app(ElementRepository::class); $baderechSet = $setRepository->find(1); + $rootIconImageUrl = $this->seedStorageFile( + 'element-icons/baderech-haavodah-icon.png', + ); + $introductionPdfPath = $this->seedStorageFile( + 'element-pdfs/baderech.pdf', + ); + $rootElement = $elementRepository->create(new CreateElementDto( set: $baderechSet, title: $baderechSet->getName(), description: $baderechSet->getDescription(), - iconImageUrl: '/assets/baderech-haavodah-icon.png', + iconImageUrl: $rootIconImageUrl, richText: '', pdfPath: null, youtubeUrl: null, @@ -36,7 +44,7 @@ class ElementSeeder extends Seeder . 'unlock the ability to live with strength, confidence, ' . 'and hope.', 'richText' => $this->introductionRichText(), - 'pdfPath' => '/assets/pdfs/baderech.pdf', + 'pdfPath' => $introductionPdfPath, ], [ 'title' => '2. Foundations', @@ -116,6 +124,22 @@ class ElementSeeder extends Seeder )); } + private function seedStorageFile( + string $storagePath, + ): string { + $sourcePath = base_path( + "database/seeders/assets/$storagePath", + ); + $contents = file_get_contents($sourcePath); + if ($contents === false) { + throw new RuntimeException("Seed file missing: $sourcePath"); + } + + Storage::disk('public')->put($storagePath, $contents); + + return $storagePath; + } + private function introductionRichText(): string { return '

' diff --git a/backend/database/seeders/assets/element-icons/baderech-haavodah-icon.png b/backend/database/seeders/assets/element-icons/baderech-haavodah-icon.png new file mode 100644 index 0000000..72603bb Binary files /dev/null and b/backend/database/seeders/assets/element-icons/baderech-haavodah-icon.png differ diff --git a/backend/database/seeders/assets/element-pdfs/baderech.pdf b/backend/database/seeders/assets/element-pdfs/baderech.pdf new file mode 100644 index 0000000..8864294 Binary files /dev/null and b/backend/database/seeders/assets/element-pdfs/baderech.pdf differ diff --git a/backend/routes/api.php b/backend/routes/api.php index 9d253d0..d3e0c43 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,5 +12,5 @@ Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); -Route::patch('/elements/{id}', [ElementController::class, 'update']) +Route::post('/element/update', [ElementController::class, 'update']) ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Fakes/FakeFileUploader.php b/backend/tests/Fakes/FakeFileUploader.php new file mode 100644 index 0000000..9273887 --- /dev/null +++ b/backend/tests/Fakes/FakeFileUploader.php @@ -0,0 +1,36 @@ + + */ + public array $uploads = []; + + /** + * @var string[] + */ + public array $deletedPaths = []; + + 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; + } + + public function delete(string $path): void + { + $this->deletedPaths[] = $path; + } +} diff --git a/backend/tests/Feature/DemoSeedDataTest.php b/backend/tests/Feature/DemoSeedDataTest.php index fcc4fe7..b802b2b 100644 --- a/backend/tests/Feature/DemoSeedDataTest.php +++ b/backend/tests/Feature/DemoSeedDataTest.php @@ -6,14 +6,27 @@ use App\Element\ElementRepository; use App\Set\SetRepository; use Database\Seeders\DatabaseSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class DemoSeedDataTest extends TestCase { use RefreshDatabase; + public function testElementSeederDoesNotReadFrontendAssets(): void + { + $seederSource = file_get_contents( + base_path('database/seeders/ElementSeeder.php'), + ); + + $this->assertIsString($seederSource); + $this->assertStringNotContainsString('../frontend/', $seederSource); + } + public function testBaderechRootElementHasDemoChildren(): void { + Storage::fake('public'); + $this->seed(DatabaseSeeder::class); $setRepository = app(SetRepository::class); @@ -23,6 +36,13 @@ class DemoSeedDataTest extends TestCase $childElements = $elementRepository->findByParentElement($rootElement); $this->assertSame('', $rootElement->getRichText()); + $this->assertSame( + 'element-icons/baderech-haavodah-icon.png', + $rootElement->getIconImageUrl(), + ); + Storage::disk('public')->assertExists( + 'element-icons/baderech-haavodah-icon.png', + ); $this->assertNull($rootElement->getPdfPath()); $this->assertNull($rootElement->getYoutubeUrl()); $this->assertCount(6, $childElements); @@ -76,9 +96,10 @@ class DemoSeedDataTest extends TestCase $childElements[0]->getRichText(), ); $this->assertSame( - '/assets/pdfs/baderech.pdf', + 'element-pdfs/baderech.pdf', $childElements[0]->getPdfPath(), ); + Storage::disk('public')->assertExists('element-pdfs/baderech.pdf'); $introductionChildElements = $elementRepository->findByParentElement( $childElements[0], diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 2ff9089..48292ce 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 @@ -120,7 +122,8 @@ class ElementsEndpointTest extends TestCase parentElement: null, )); - $response = $this->patchJson("/api/elements/{$element->getId()}", [ + $response = $this->postJson('/api/element/update', [ + 'elementId' => $element->getId(), 'title' => 'Updated title', 'description' => 'Updated description', 'iconImageUrl' => '/assets/updated-icon.png', @@ -160,7 +163,8 @@ class ElementsEndpointTest extends TestCase $response = $this->withCredentials() ->withUnencryptedCookie('auth_token', 'valid-token') - ->patchJson("/api/elements/{$element->getId()}", [ + ->postJson('/api/element/update', [ + 'elementId' => $element->getId(), 'title' => 'Updated title', 'description' => 'Updated description', 'iconImageUrl' => '/assets/updated-icon.png', @@ -189,6 +193,225 @@ class ElementsEndpointTest extends TestCase ); } + public function testAuthenticatedUpdateClearsUploadedMedia(): void + { + Storage::fake('public'); + Storage::disk('public')->put( + 'element-icons/original-icon.png', + 'icon bytes', + ); + Storage::disk('public')->put( + 'element-pdfs/original.pdf', + 'pdf bytes', + ); + $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: 'element-icons/original-icon.png', + richText: '

A structured path for growth

', + pdfPath: 'element-pdfs/original.pdf', + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->postJson('/api/element/update', [ + 'elementId' => $element->getId(), + 'iconImageUrl' => '', + 'pdfPath' => '', + ]); + + $response->assertOk(); + $body = $response->json(); + $this->assertNull($body['element']['iconImageUrl']); + $this->assertNull($body['element']['pdfPath']); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertNotNull($updatedElement); + $this->assertNull($updatedElement->getIconImageUrl()); + $this->assertNull($updatedElement->getPdfPath()); + Storage::disk('public')->assertMissing( + 'element-icons/original-icon.png', + ); + Storage::disk('public')->assertMissing( + 'element-pdfs/original.pdf', + ); + } + + 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/element/update', + [ + 'elementId' => (string) $element->getId(), + '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/element/update', + [ + 'elementId' => (string) $element->getId(), + '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); + } + + public function testPdfUploadRequiresAuthentication(): 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/element/update', + [ + 'elementId' => (string) $element->getId(), + 'pdf' => $this->pdfUpload(), + ], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedPdfUploadReturnsElementPayload(): 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/element/update', + [ + 'elementId' => (string) $element->getId(), + 'pdf' => $this->pdfUpload(), + ], + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertStringContainsString( + '/storage/element-pdfs/', + $body['element']['pdfPath'], + ); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertNotNull($updatedElement); + $updatedPdfPath = $updatedElement->getPdfPath(); + $this->assertIsString($updatedPdfPath); + $this->assertStringStartsWith('element-pdfs/', $updatedPdfPath); + Storage::disk('public')->assertExists($updatedPdfPath); + } + private function createSession(string $token): void { $userRepository = app(UserRepository::class); @@ -211,4 +434,30 @@ class ElementsEndpointTest extends TestCase ), )); } + + private function iconImageUpload(): UploadedFile + { + $fixturePath = __DIR__ . '/../fixtures/icon.png'; + + return new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ); + } + + private function pdfUpload(): UploadedFile + { + $fixturePath = __DIR__ . '/../fixtures/baderech.pdf'; + + return new UploadedFile( + $fixturePath, + 'baderech.pdf', + 'application/pdf', + null, + true, + ); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 99bfa3e..5416d92 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -8,13 +8,18 @@ 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\UpdateIconImage; use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; +use App\Element\UseCases\UpdateElement\UpdatePdf; 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,20 +28,35 @@ 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), new UpdateDescription($this->elementRepo), - new UpdateIconImageUrl($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo, $this->fileUploader), new UpdateRichText($this->elementRepo), - new UpdatePdfPath($this->elementRepo), + new UpdatePdfPath($this->elementRepo, $this->fileUploader), new UpdateYoutubeUrl($this->elementRepo), + new UpdateIconImage( + $this->elementRepo, + $this->fileUploader, + ), + new UpdatePdf( + $this->elementRepo, + $this->fileUploader, + ), $this->elementRepo, ); - $this->controller = new ElementController($getElement, $updateElement); + $this->controller = new ElementController( + $getElement, + $updateElement, + $this->fileUploader, + ); } public function testShowReturnsElementPayload(): void @@ -135,6 +155,88 @@ class ElementControllerTest extends TestCase ); } + public function testUpdateWithIconImageReturnsElementPayload(): 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__ . '/../../fixtures/icon.png'; + $request = new Request([], [ + 'elementId' => (string) $element->getId(), + ], [], [], [ + 'iconImage' => new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ), + ], ['REQUEST_METHOD' => 'POST']); + + $response = $this->controller->update($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'], + ); + } + + public function testUpdateWithPdfReturnsElementPayload(): 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__ . '/../../fixtures/baderech.pdf'; + $request = new Request([], [ + 'elementId' => (string) $element->getId(), + ], [], [], [ + 'pdf' => new UploadedFile( + $fixturePath, + 'baderech.pdf', + 'application/pdf', + null, + true, + ), + ], ['REQUEST_METHOD' => 'POST']); + + $response = $this->controller->update($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-pdfs/fake-baderech.pdf', + $body['element']['pdfPath'], + ); + $this->assertSame( + 'element-pdfs', + $this->fileUploader->uploads[0]['folder'], + ); + } + private function createSet(int $id, string $name): DomainSet { return new DomainSet( diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php index 7c27c9c..ca39700 100644 --- a/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php +++ b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php @@ -19,15 +19,19 @@ use App\Element\UseCases\UpdateElement\UpdateYoutubeUrlRequest; use App\Exceptions\BadRequestException; use App\Set\Set as DomainSet; use Tests\Fakes\FakeElementRepository; +use Tests\Fakes\FakeFileUploader; use Tests\TestCase; class UpdateElementFieldsTest extends TestCase { private FakeElementRepository $elementRepo; + private FakeFileUploader $fileUploader; + protected function setUp(): void { $this->elementRepo = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); } public function testUpdateTitleUpdatesOnlyTitle(): void @@ -88,7 +92,10 @@ class UpdateElementFieldsTest extends TestCase public function testUpdateIconImageUrlClearsEmptyString(): void { $element = $this->createFilledElement(); - $updateIconImageUrl = new UpdateIconImageUrl($this->elementRepo); + $updateIconImageUrl = new UpdateIconImageUrl( + $this->elementRepo, + $this->fileUploader, + ); $updatedElement = $updateIconImageUrl->execute( new UpdateIconImageUrlRequest( @@ -99,6 +106,10 @@ class UpdateElementFieldsTest extends TestCase $this->assertNull($updatedElement->getIconImageUrl()); $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + $this->assertSame( + ['/assets/original-icon.png'], + $this->fileUploader->deletedPaths, + ); } public function testUpdateRichTextUpdatesOnlyRichText(): void @@ -126,7 +137,10 @@ class UpdateElementFieldsTest extends TestCase public function testUpdatePdfPathClearsEmptyString(): void { $element = $this->createFilledElement(); - $updatePdfPath = new UpdatePdfPath($this->elementRepo); + $updatePdfPath = new UpdatePdfPath( + $this->elementRepo, + $this->fileUploader, + ); $updatedElement = $updatePdfPath->execute( new UpdatePdfPathRequest(id: $element->getId(), pdfPath: '') @@ -134,6 +148,10 @@ class UpdateElementFieldsTest extends TestCase $this->assertNull($updatedElement->getPdfPath()); $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + $this->assertSame( + ['/assets/pdfs/original.pdf'], + $this->fileUploader->deletedPaths, + ); } public function testUpdateYoutubeUrlClearsEmptyString(): void @@ -154,6 +172,16 @@ class UpdateElementFieldsTest extends TestCase private function createFilledElement(): Element { + return $this->createElementWithMedia( + '/assets/original-icon.png', + '/assets/pdfs/original.pdf', + ); + } + + private function createElementWithMedia( + ?string $iconImageUrl, + ?string $pdfPath, + ): Element { $set = new DomainSet( id: 1, name: 'Baderech', @@ -165,9 +193,9 @@ class UpdateElementFieldsTest extends TestCase set: $set, title: 'Original title', description: 'Original description', - iconImageUrl: '/assets/original-icon.png', + iconImageUrl: $iconImageUrl, richText: '

Original rich text

', - pdfPath: '/assets/pdfs/original.pdf', + pdfPath: $pdfPath, youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU', parentElement: null, )); diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php index 0ce0d88..8d823e3 100644 --- a/backend/tests/Unit/Element/UseCases/UpdateElementTest.php +++ b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php @@ -5,10 +5,12 @@ namespace Tests\Unit\Element\UseCases; use App\Element\CreateElementDto; use App\Element\Element; use App\Element\UseCases\UpdateElement\UpdateDescription; -use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementRequest; +use App\Element\UseCases\UpdateElement\UpdateIconImage; +use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; use App\Element\UseCases\UpdateElement\UpdatePdfPath; +use App\Element\UseCases\UpdateElement\UpdatePdf; use App\Element\UseCases\UpdateElement\UpdateRichText; use App\Element\UseCases\UpdateElement\UpdateTitle; use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl; @@ -16,24 +18,36 @@ use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; use App\Set\Set as DomainSet; use Tests\Fakes\FakeElementRepository; +use Tests\Fakes\FakeFileUploader; use Tests\TestCase; class UpdateElementTest extends TestCase { private FakeElementRepository $elementRepo; + private FakeFileUploader $fileUploader; + private UpdateElement $updateElement; protected function setUp(): void { $this->elementRepo = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); $this->updateElement = new UpdateElement( new UpdateTitle($this->elementRepo), new UpdateDescription($this->elementRepo), - new UpdateIconImageUrl($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo, $this->fileUploader), new UpdateRichText($this->elementRepo), - new UpdatePdfPath($this->elementRepo), + new UpdatePdfPath($this->elementRepo, $this->fileUploader), new UpdateYoutubeUrl($this->elementRepo), + new UpdateIconImage( + $this->elementRepo, + $this->fileUploader, + ), + new UpdatePdf( + $this->elementRepo, + $this->fileUploader, + ), $this->elementRepo, ); } @@ -61,6 +75,14 @@ class UpdateElementTest extends TestCase richText: '

Updated rich text

', pdfPath: '/assets/pdfs/updated.pdf', youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, ) ); @@ -115,6 +137,14 @@ class UpdateElementTest extends TestCase richText: '

Updated rich text

', pdfPath: '', youtubeUrl: '', + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, ) ); @@ -146,6 +176,14 @@ class UpdateElementTest extends TestCase richText: null, pdfPath: null, youtubeUrl: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, ) ); @@ -172,6 +210,58 @@ class UpdateElementTest extends TestCase ); } + public function testUpdatesUploadedFiles(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + null, + '

Original rich text

', + null, + null, + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: null, + description: null, + iconImageUrl: null, + richText: null, + pdfPath: null, + youtubeUrl: null, + iconImageContents: 'binary-image-bytes', + iconImageOriginalName: 'icon.png', + iconImageMimeType: 'image/png', + iconImageSizeBytes: 1024, + pdfContents: 'binary-pdf-bytes', + pdfOriginalName: 'baderech.pdf', + pdfMimeType: 'application/pdf', + pdfSizeBytes: 1024, + ) + ); + + $this->assertSame( + 'element-icons/fake-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + 'element-pdfs/fake-baderech.pdf', + $updatedElement->getPdfPath(), + ); + $this->assertSame( + 'element-icons', + $this->fileUploader->uploads[0]['folder'], + ); + $this->assertSame( + 'element-pdfs', + $this->fileUploader->uploads[1]['folder'], + ); + } + public function testThrowsWhenIdMissing(): void { $this->expectException(BadRequestException::class); @@ -185,6 +275,14 @@ class UpdateElementTest extends TestCase richText: '

Updated rich text

', pdfPath: null, youtubeUrl: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, )); } @@ -201,6 +299,14 @@ class UpdateElementTest extends TestCase richText: null, pdfPath: null, youtubeUrl: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, )); } @@ -217,6 +323,14 @@ class UpdateElementTest extends TestCase richText: '

Updated rich text

', pdfPath: null, youtubeUrl: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, )); } @@ -233,6 +347,14 @@ class UpdateElementTest extends TestCase richText: '

Updated rich text

', pdfPath: null, youtubeUrl: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, )); } diff --git a/backend/tests/Unit/Element/UseCases/UpdateIconImageTest.php b/backend/tests/Unit/Element/UseCases/UpdateIconImageTest.php new file mode 100644 index 0000000..af8ae7f --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateIconImageTest.php @@ -0,0 +1,175 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->useCase = new UpdateIconImage( + $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 UpdateIconImageRequest( + ...$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 UpdateIconImageRequest( + ...$this->imageArgs(), + id: null, + ) + ); + } + + public function testNullIconImageThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image is required'); + + $this->useCase->execute( + new UpdateIconImageRequest( + 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 UpdateIconImageRequest( + 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 UpdateIconImageRequest( + 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 UpdateIconImageRequest( + ...$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, + )); + } +} diff --git a/backend/tests/Unit/Element/UseCases/UpdatePdfTest.php b/backend/tests/Unit/Element/UseCases/UpdatePdfTest.php new file mode 100644 index 0000000..9a3b30b --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdatePdfTest.php @@ -0,0 +1,173 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->useCase = new UpdatePdf( + $this->elementRepository, + $this->fileUploader, + ); + } + + /** + * @return array{ + * pdfContents: string, + * pdfOriginalName: string, + * pdfMimeType: string, + * pdfSizeBytes: int + * } + */ + private function pdfArgs(): array + { + return [ + 'pdfContents' => 'binary-pdf-bytes', + 'pdfOriginalName' => 'baderech.pdf', + 'pdfMimeType' => 'application/pdf', + 'pdfSizeBytes' => 1024, + ]; + } + + public function testUploadsPdfAndStoresPath(): void + { + $element = $this->createElement(); + + $updatedElement = $this->useCase->execute( + new UpdatePdfRequest( + ...$this->pdfArgs(), + id: $element->getId(), + ) + ); + + $this->assertSame( + 'element-pdfs/fake-baderech.pdf', + $updatedElement->getPdfPath(), + ); + $storedElement = $this->elementRepository->find($element->getId()); + $this->assertNotNull($storedElement); + $this->assertSame( + 'element-pdfs/fake-baderech.pdf', + $storedElement->getPdfPath(), + ); + $this->assertSame( + 'element-pdfs', + $this->fileUploader->uploads[0]['folder'], + ); + } + + public function testNullIdThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->useCase->execute( + new UpdatePdfRequest( + ...$this->pdfArgs(), + id: null, + ) + ); + } + + public function testNullPdfThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf is required'); + + $this->useCase->execute( + new UpdatePdfRequest( + id: 1, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, + ) + ); + } + + public function testDisallowedMimeThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf must be a pdf file'); + + $this->useCase->execute( + new UpdatePdfRequest( + id: 1, + pdfContents: 'not-a-pdf', + pdfOriginalName: 'icon.png', + pdfMimeType: 'image/png', + pdfSizeBytes: 1024, + ) + ); + } + + public function testOversizedPdfThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf must be 10MB or smaller'); + + $this->useCase->execute( + new UpdatePdfRequest( + id: 1, + pdfContents: 'big', + pdfOriginalName: 'baderech.pdf', + pdfMimeType: 'application/pdf', + pdfSizeBytes: 11 * 1024 * 1024, + ) + ); + } + + public function testMissingElementThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->useCase->execute( + new UpdatePdfRequest( + ...$this->pdfArgs(), + 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, + )); + } +} diff --git a/backend/tests/fixtures/baderech.pdf b/backend/tests/fixtures/baderech.pdf new file mode 100644 index 0000000..8864294 Binary files /dev/null and b/backend/tests/fixtures/baderech.pdf differ diff --git a/backend/tests/fixtures/icon.png b/backend/tests/fixtures/icon.png new file mode 100644 index 0000000..72603bb Binary files /dev/null and b/backend/tests/fixtures/icon.png differ diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 8678ec2..38dc7a1 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -12,26 +12,17 @@ function loginAsAdmin(): void { function fillElementForm( title: string, description: string, - iconImageUrl: string, richText: string, - pdfPath: string, youtubeUrl: string, ): void { cy.get('[data-cy="admin-element-title"]').clear().type(title) cy.get('[data-cy="admin-element-description"]').clear().type(description) - cy.get('[data-cy="admin-element-icon-image-url"]') - .clear() - .type(iconImageUrl) cy.get('[data-cy="admin-element-rich-text"]').clear() if (richText !== '') { cy.get('[data-cy="admin-element-rich-text"]').type(richText, { parseSpecialCharSequences: false, }) } - cy.get('[data-cy="admin-element-pdf-path"]').clear() - if (pdfPath !== '') { - cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath) - } cy.get('[data-cy="admin-element-youtube-url"]').clear() if (youtubeUrl !== '') { cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl) @@ -79,7 +70,6 @@ describe('admin element editing', () => { it('saves element edits and restores the seed content through the UI', () => { const originalTitle = 'Baderech HaAvodah' const originalDescription = 'a structured path for inner and outer growth' - const originalIconImageUrl = '/assets/baderech-haavodah-icon.png' const updatedTitle = 'Baderech HaAvodah Updated' const updatedDescription = 'Updated admin description' const updatedRichText = '

Updated admin rich text

' @@ -90,10 +80,8 @@ describe('admin element editing', () => { fillElementForm( updatedTitle, updatedDescription, - originalIconImageUrl, updatedRichText, '', - '', ) cy.get('[data-cy="admin-element-save"]').click() @@ -110,8 +98,6 @@ describe('admin element editing', () => { fillElementForm( originalTitle, originalDescription, - originalIconImageUrl, - '', '', '', ) @@ -120,4 +106,89 @@ describe('admin element editing', () => { .should('be.visible') .and('contain.text', 'Saved') }) + + it('uploads icon and pdf files immediately through the UI', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('POST', /\/api\/element\/update$/).as('updateElement') + + cy.get('[data-cy="admin-element-icon-image-input"]').selectFile( + 'public/assets/baderech-haavodah-icon.png', + { force: true }, + ) + cy.wait('@updateElement') + cy.get('[data-cy="admin-element-icon-image-status"]') + .should('be.visible') + .and('contain.text', 'Icon image updated') + cy.get('[data-cy="admin-element-current-icon"]') + .should('be.visible') + .and('have.attr', 'src') + .and('include', '/storage/element-icons/') + + cy.get('[data-cy="admin-element-pdf-input"]').selectFile( + 'public/assets/pdfs/baderech.pdf', + { force: true }, + ) + cy.wait('@updateElement') + cy.get('[data-cy="admin-element-pdf-status"]') + .should('be.visible') + .and('contain.text', 'PDF updated') + cy.get('[data-cy="admin-element-current-pdf"]') + .should('be.visible') + .and('have.attr', 'href') + .and('include', '/storage/element-pdfs/') + + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="element-icon"]') + .should('be.visible') + .and('have.attr', 'src') + .and('include', '/storage/element-icons/') + cy.get('[data-cy="element-pdf-link"]') + .should('be.visible') + .and('have.attr', 'href') + .and('include', '/storage/element-pdfs/') + + cy.resetDb() + }) + + it('removes uploaded icon and pdf files immediately through the UI', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('POST', /\/api\/element\/update$/).as('updateElement') + + cy.get('[data-cy="admin-element-icon-image-input"]').selectFile( + 'public/assets/baderech-haavodah-icon.png', + { force: true }, + ) + cy.wait('@updateElement') + cy.get('[data-cy="admin-element-pdf-input"]').selectFile( + 'public/assets/pdfs/baderech.pdf', + { force: true }, + ) + cy.wait('@updateElement') + + cy.contains('button', 'Remove icon image').click() + cy.wait('@updateElement') + cy.get('[data-cy="admin-element-icon-image-status"]') + .should('be.visible') + .and('contain.text', 'Icon image removed') + cy.get('[data-cy="admin-element-current-icon"]').should('not.exist') + cy.contains('No icon image').should('be.visible') + + cy.contains('button', 'Remove PDF').click() + cy.wait('@updateElement') + cy.get('[data-cy="admin-element-pdf-status"]') + .should('be.visible') + .and('contain.text', 'PDF removed') + cy.get('[data-cy="admin-element-current-pdf"]').should('not.exist') + cy.contains('No PDF').should('be.visible') + + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="element-icon"]').should('not.exist') + cy.get('[data-cy="element-pdf-link"]').should('not.exist') + + cy.resetDb() + }) }) diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index b3e1424..e0f361a 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -47,7 +47,7 @@ describe('media page sets', () => { cy.get('[data-cy="element-icon"]') .should('be.visible') .and('have.attr', 'src') - .and('include', '/assets/baderech-haavodah-icon.png') + .and('include', '/storage/element-icons/baderech-haavodah-icon.png') cy.get('[data-cy="element-rich-text"]').should('not.exist') cy.get('[data-cy="element-youtube-embed"]') .should('not.exist') @@ -117,9 +117,12 @@ describe('media page sets', () => { .should('have.css', 'text-align', 'center') cy.get('[data-cy="element-youtube-embed"]').should('not.exist') cy.contains('[data-cy="element-pdf-link"]', 'View PDF') + .as('pdfLink') .should('be.visible') - .and('have.attr', 'href', '/assets/pdfs/baderech.pdf') .and('have.attr', 'target', '_blank') + cy.get('@pdfLink') + .and('have.attr', 'href') + .and('include', '/storage/element-pdfs/baderech.pdf') cy.contains('[data-cy="child-element-link"]', introductionAudioTitle) .as('introductionAudioLink') .should('have.attr', 'href', '/element/8') diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 9da179f..3155637 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -22,9 +22,7 @@ interface ElementResponse { export interface UpdateElementInput { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } @@ -32,11 +30,21 @@ interface UpdateElementResponse { element: Element } +interface ElementPatchInput { + title?: string + description?: string + iconImageUrl?: string + richText?: string + pdfPath?: string + youtubeUrl?: string +} + interface ErrorResponse { error?: string } const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string +const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update` export const useElementsStore = defineStore('elements', () => { const element = ref(null) @@ -44,6 +52,8 @@ export const useElementsStore = defineStore('elements', () => { const isLoading = ref(false) const error = ref(null) const isSaving = ref(false) + const isUploadingIconImage = ref(false) + const isUploadingPdf = ref(false) const saveError = ref(null) async function fetchElement(elementId: string): Promise { @@ -80,59 +90,142 @@ export const useElementsStore = defineStore('elements', () => { } async function updateElement(elementId: string, input: UpdateElementInput): Promise { + return await saveElementPatch(elementId, input, 'Could not save element') + } + + async function clearElementIconImage(elementId: string): Promise { + return await saveElementPatch(elementId, { iconImageUrl: '' }, 'Could not remove icon image') + } + + async function clearElementPdf(elementId: string): Promise { + return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF') + } + + async function saveElementPatch( + elementId: string, + input: ElementPatchInput, + failureMessage: string, + ): Promise { saveError.value = null isSaving.value = true try { - const encodedElementId = encodeURIComponent(elementId) - const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` - const response = await fetch(elementUrl, { - method: 'PATCH', + const response = await fetch(ELEMENT_UPDATE_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify(input), + body: JSON.stringify({ elementId, ...input }), }) - if (response.status === 401) { - saveError.value = 'Please log in again' - return false - } - - if (response.status === 404) { - saveError.value = 'Element not found' - return false - } - - if (response.status === 400) { - const data: ErrorResponse = await response.json() - saveError.value = data.error ?? 'Could not save element' - return false - } - - if (!response.ok) { - saveError.value = 'Could not save element' - return false - } - - const data: UpdateElementResponse = await response.json() - element.value = data.element - return true + return await handleElementResponse(response, failureMessage) } catch { - saveError.value = 'Network error - could not save element' + saveError.value = `Network error - ${failureMessage.toLowerCase()}` return false } finally { isSaving.value = false } } + async function uploadElementIconImage(elementId: string, file: File): Promise { + saveError.value = null + isUploadingIconImage.value = true + + try { + const formData = new FormData() + formData.append('elementId', elementId) + formData.append('iconImage', file) + const response = await fetch(ELEMENT_UPDATE_URL, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: formData, + }) + + return await handleElementResponse(response, 'Could not upload icon image') + } catch { + saveError.value = 'Network error - could not upload icon image' + return false + } finally { + isUploadingIconImage.value = false + } + } + + async function uploadElementPdf(elementId: string, file: File): Promise { + saveError.value = null + isUploadingPdf.value = true + + try { + const formData = new FormData() + formData.append('elementId', elementId) + formData.append('pdf', file) + const response = await fetch(ELEMENT_UPDATE_URL, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: formData, + }) + + return await handleElementResponse(response, 'Could not upload PDF') + } catch { + saveError.value = 'Network error - could not upload PDF' + return false + } finally { + isUploadingPdf.value = false + } + } + + async function handleElementResponse( + response: Response, + failureMessage: string, + ): Promise { + if (response.status === 401) { + saveError.value = 'Please log in again' + return false + } + + if (response.status === 404) { + saveError.value = 'Element not found' + return false + } + + if (response.status === 400) { + saveError.value = await errorMessage(response, failureMessage) + return false + } + + if (!response.ok) { + saveError.value = failureMessage + return false + } + + const data: UpdateElementResponse = await response.json() + element.value = data.element + return true + } + + async function errorMessage(response: Response, fallbackMessage: string): Promise { + try { + const data: ErrorResponse = await response.json() + return data.error ?? fallbackMessage + } catch { + return fallbackMessage + } + } + return { element, childElements, isLoading, error, isSaving, + isUploadingIconImage, + isUploadingPdf, saveError, fetchElement, updateElement, + uploadElementIconImage, + uploadElementPdf, + clearElementIconImage, + clearElementPdf, } }) diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index cc19049..37f03ff 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -8,23 +8,24 @@ import { useElementsStore } from '@/stores/elements' interface ElementForm { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } const route = useRoute() const elementsStore = useElementsStore() -const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore) +const { element, isLoading, error, isSaving, isUploadingIconImage, isUploadingPdf, saveError } = + storeToRefs(elementsStore) const savedMessage = ref(null) +const iconImageStatus = ref(null) +const pdfStatus = ref(null) +const iconImageInput = ref(null) +const pdfInput = ref(null) const form = reactive({ title: '', description: '', - iconImageUrl: '', richText: '', - pdfPath: '', youtubeUrl: '', }) @@ -54,6 +55,8 @@ watch( } savedMessage.value = null + iconImageStatus.value = null + pdfStatus.value = null void elementsStore.fetchElement(currentElementId) }, { immediate: true }, @@ -66,9 +69,7 @@ watch(element, (currentElement) => { form.title = currentElement.title form.description = currentElement.description - form.iconImageUrl = currentElement.iconImageUrl ?? '' form.richText = currentElement.richText - form.pdfPath = currentElement.pdfPath ?? '' form.youtubeUrl = currentElement.youtubeUrl ?? '' }) @@ -81,9 +82,7 @@ async function handleSubmit(): Promise { const saved = await elementsStore.updateElement(elementId.value, { title: form.title, description: form.description, - iconImageUrl: form.iconImageUrl, richText: form.richText, - pdfPath: form.pdfPath, youtubeUrl: form.youtubeUrl, }) @@ -91,6 +90,92 @@ async function handleSubmit(): Promise { savedMessage.value = 'Saved' } } + +function chooseIconImage(): void { + iconImageInput.value?.click() +} + +function choosePdf(): void { + pdfInput.value?.click() +} + +async function handleIconImageChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const uploaded = await elementsStore.uploadElementIconImage(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + iconImageStatus.value = 'Icon image updated' + } +} + +async function handlePdfChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const uploaded = await elementsStore.uploadElementPdf(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + pdfStatus.value = 'PDF updated' + } +} + +async function handleRemoveIconImage(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const removed = await elementsStore.clearElementIconImage(elementId.value) + if (removed) { + iconImageStatus.value = 'Icon image removed' + } +} + +async function handleRemovePdf(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const removed = await elementsStore.clearElementPdf(elementId.value) + if (removed) { + pdfStatus.value = 'PDF removed' + } +} + +function selectedInputFile(changeEvent: Event): File | null { + const target = changeEvent.target + if (!(target instanceof HTMLInputElement)) { + return null + } + + if (target.files === null || target.files.length === 0) { + return null + } + + return target.files.item(0) +} + +function resetInput(changeEvent: Event): void { + const target = changeEvent.target + if (target instanceof HTMLInputElement) { + target.value = '' + } +}