Compare commits

..

20 commits

Author SHA1 Message Date
1f24f4e317
Merge branch 'feature/element-file-uploads' 2026-06-03 08:47:20 +03:00
4cf45a761d
use backend seed assets 2026-06-03 08:45:32 +03:00
6c14e193e0
test backend seed assets 2026-06-03 08:43:54 +03:00
01434a9b2d
simplify media deletion 2026-06-02 17:12:44 +03:00
247433bc60
test storage media seeds 2026-06-02 17:08:54 +03:00
1934a6b94f
fix media removal 2026-06-02 17:00:33 +03:00
4b432d9b99
test media removal 2026-06-02 16:58:00 +03:00
d0810ba4b3
rename upload use cases 2026-06-02 16:38:59 +03:00
bd38e350cf
test shorter upload names 2026-06-02 16:37:35 +03:00
1d9e2cff0a
use element update route 2026-06-02 16:33:18 +03:00
9cb968afd8
test element update route 2026-06-02 16:28:21 +03:00
299ccc4f58
use element update route 2026-06-02 16:11:21 +03:00
71bb131687
use element update uploads 2026-06-02 16:08:58 +03:00
b6da71ecd2
test unified uploads 2026-06-02 16:06:59 +03:00
a2c3ffad8b
add element upload UI 2026-06-02 10:30:48 +03:00
4292494345
test element uploads 2026-06-02 10:24:48 +03:00
cd2e63d17d
add pdf uploads 2026-06-02 10:22:53 +03:00
1fbe198d8c
test pdf uploads 2026-06-02 10:21:28 +03:00
97ecbebb9e
add icon uploads 2026-06-02 10:19:28 +03:00
0f3bb6de6b
test icon uploads 2026-06-02 10:17:21 +03:00
31 changed files with 1884 additions and 104 deletions

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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,
) {
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Shared\Files\FileToUpload;
use App\Shared\Files\FileUploader;
class UpdateIconImage
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
private const MAX_SIZE_BYTES = 5 * 1024 * 1024;
public function __construct(
private ElementRepository $elementRepository,
private FileUploader $fileUploader,
) {
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateIconImageRequest $request): Element
{
if ($request->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);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateIconImageRequest
{
public function __construct(
public ?int $id,
public ?string $iconImageContents,
public ?string $iconImageOriginalName,
public ?string $iconImageMimeType,
public ?int $iconImageSizeBytes,
) {
}
}

View file

@ -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 UpdateIconImageUrl
{
public function __construct(private ElementRepository $elementRepository)
{
public function __construct(
private ElementRepository $elementRepository,
private FileUploader $fileUploader,
) {
}
/**
@ -32,9 +35,12 @@ class UpdateIconImageUrl
throw new NotFoundException('Element not found');
}
$element->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);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Shared\Files\FileToUpload;
use App\Shared\Files\FileUploader;
class UpdatePdf
{
private const ALLOWED_MIME_TYPES = [
'application/pdf',
];
private const MAX_SIZE_BYTES = 10 * 1024 * 1024;
public function __construct(
private ElementRepository $elementRepository,
private FileUploader $fileUploader,
) {
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdatePdfRequest $request): Element
{
if ($request->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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdatePdfRequest
{
public function __construct(
public ?int $id,
public ?string $pdfContents,
public ?string $pdfOriginalName,
public ?string $pdfMimeType,
public ?int $pdfSizeBytes,
) {
}
}

View file

@ -8,6 +8,8 @@ use App\Auth\PasswordHasher;
use App\Auth\RandomTokenGenerator;
use App\Auth\SystemClock;
use App\Auth\TokenGenerator;
use App\Shared\Files\FileUploader;
use App\Shared\Files\LaravelFileUploader;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -26,5 +28,9 @@ class AppServiceProvider extends ServiceProvider
Clock::class,
SystemClock::class,
);
$this->app->bind(
FileUploader::class,
LaravelFileUploader::class,
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Shared\Files;
final readonly class FileToUpload
{
public function __construct(
private string $contents,
private string $originalName,
private string $mimeType,
private int $sizeBytes,
) {
}
public function getContents(): string
{
return $this->contents;
}
public function getOriginalName(): string
{
return $this->originalName;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function getSizeBytes(): int
{
return $this->sizeBytes;
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Shared\Files;
interface FileUploader
{
public function upload(FileToUpload $file, string $folder): string;
public function url(string $path): string;
public function delete(string $path): void;
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Shared\Files;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class LaravelFileUploader implements FileUploader
{
public function upload(FileToUpload $file, string $folder): string
{
$path = $folder . '/' . Str::random(40) . $this->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 '';
}
}

View file

@ -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 '<h2 style="text-align: center;">'

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -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);

View file

@ -0,0 +1,36 @@
<?php
namespace Tests\Fakes;
use App\Shared\Files\FileToUpload;
use App\Shared\Files\FileUploader;
class FakeFileUploader implements FileUploader
{
/**
* @var array<int, array{file: FileToUpload, folder: string}>
*/
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;
}
}

View file

@ -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],

View file

@ -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: '<p>A structured path for growth</p>',
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: '<p>A structured path for growth</p>',
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: '<p>A structured path for growth</p>',
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: '<p>A structured path for growth</p>',
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: '<p>A structured path for growth</p>',
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,
);
}
}

View file

@ -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,
'<p>A structured path for growth</p>',
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,
'<p>A structured path for growth</p>',
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(

View file

@ -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: '<p>Original rich text</p>',
pdfPath: '/assets/pdfs/original.pdf',
pdfPath: $pdfPath,
youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU',
parentElement: null,
));

View file

@ -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: '<p>Updated rich text</p>',
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: '<p>Updated rich text</p>',
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,
'<p>Original rich text</p>',
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: '<p>Updated rich text</p>',
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: '<p>Updated rich text</p>',
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: '<p>Updated rich text</p>',
pdfPath: null,
youtubeUrl: null,
iconImageContents: null,
iconImageOriginalName: null,
iconImageMimeType: null,
iconImageSizeBytes: null,
pdfContents: null,
pdfOriginalName: null,
pdfMimeType: null,
pdfSizeBytes: null,
));
}

View file

@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\UpdateElement\UpdateIconImage;
use App\Element\UseCases\UpdateElement\UpdateIconImageRequest;
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 UpdateIconImageTest extends TestCase
{
private FakeElementRepository $elementRepository;
private FakeFileUploader $fileUploader;
private UpdateIconImage $useCase;
protected function setUp(): void
{
$this->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,
));
}
}

View file

@ -0,0 +1,173 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\UpdateElement\UpdatePdf;
use App\Element\UseCases\UpdateElement\UpdatePdfRequest;
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 UpdatePdfTest extends TestCase
{
private FakeElementRepository $elementRepository;
private FakeFileUploader $fileUploader;
private UpdatePdf $useCase;
protected function setUp(): void
{
$this->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,
));
}
}

BIN
backend/tests/fixtures/baderech.pdf vendored Normal file

Binary file not shown.

BIN
backend/tests/fixtures/icon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -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 = '<p>Updated admin rich text</p>'
@ -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()
})
})

View file

@ -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')

View file

@ -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<Element | null>(null)
@ -44,6 +52,8 @@ export const useElementsStore = defineStore('elements', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
const isSaving = ref(false)
const isUploadingIconImage = ref(false)
const isUploadingPdf = ref(false)
const saveError = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> {
@ -80,19 +90,94 @@ export const useElementsStore = defineStore('elements', () => {
}
async function updateElement(elementId: string, input: UpdateElementInput): Promise<boolean> {
return await saveElementPatch(elementId, input, 'Could not save element')
}
async function clearElementIconImage(elementId: string): Promise<boolean> {
return await saveElementPatch(elementId, { iconImageUrl: '' }, 'Could not remove icon image')
}
async function clearElementPdf(elementId: string): Promise<boolean> {
return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF')
}
async function saveElementPatch(
elementId: string,
input: ElementPatchInput,
failureMessage: string,
): Promise<boolean> {
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 }),
})
return await handleElementResponse(response, failureMessage)
} catch {
saveError.value = `Network error - ${failureMessage.toLowerCase()}`
return false
} finally {
isSaving.value = false
}
}
async function uploadElementIconImage(elementId: string, file: File): Promise<boolean> {
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<boolean> {
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<boolean> {
if (response.status === 401) {
saveError.value = 'Please log in again'
return false
@ -104,24 +189,26 @@ export const useElementsStore = defineStore('elements', () => {
}
if (response.status === 400) {
const data: ErrorResponse = await response.json()
saveError.value = data.error ?? 'Could not save element'
saveError.value = await errorMessage(response, failureMessage)
return false
}
if (!response.ok) {
saveError.value = 'Could not save element'
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<string> {
try {
const data: ErrorResponse = await response.json()
return data.error ?? fallbackMessage
} catch {
saveError.value = 'Network error - could not save element'
return false
} finally {
isSaving.value = false
return fallbackMessage
}
}
@ -131,8 +218,14 @@ export const useElementsStore = defineStore('elements', () => {
isLoading,
error,
isSaving,
isUploadingIconImage,
isUploadingPdf,
saveError,
fetchElement,
updateElement,
uploadElementIconImage,
uploadElementPdf,
clearElementIconImage,
clearElementPdf,
}
})

View file

@ -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<string | null>(null)
const iconImageStatus = ref<string | null>(null)
const pdfStatus = ref<string | null>(null)
const iconImageInput = ref<HTMLInputElement | null>(null)
const pdfInput = ref<HTMLInputElement | null>(null)
const form = reactive<ElementForm>({
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<void> {
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<void> {
savedMessage.value = 'Saved'
}
}
function chooseIconImage(): void {
iconImageInput.value?.click()
}
function choosePdf(): void {
pdfInput.value?.click()
}
async function handleIconImageChange(changeEvent: Event): Promise<void> {
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<void> {
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<void> {
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<void> {
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 = ''
}
}
</script>
<template>
@ -130,15 +215,53 @@ async function handleSubmit(): Promise<void> {
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Icon Image URL</span>
<input
v-model="form.iconImageUrl"
class="admin-element-page__input"
data-cy="admin-element-icon-image-url"
type="text"
<section class="admin-element-page__media-field">
<span class="admin-element-page__label">Icon Image</span>
<img
v-if="element.iconImageUrl !== null"
:src="element.iconImageUrl"
:alt="`${element.title} icon`"
class="admin-element-page__icon-preview"
data-cy="admin-element-current-icon"
/>
</label>
<p v-else class="admin-element-page__empty-media">No icon image</p>
<div class="admin-element-page__media-actions">
<input
ref="iconImageInput"
class="admin-element-page__file-input"
data-cy="admin-element-icon-image-input"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="isUploadingIconImage"
@change="handleIconImageChange"
/>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingIconImage"
@click="chooseIconImage"
>
{{ isUploadingIconImage ? 'Uploading...' : 'Choose icon image' }}
</button>
<button
v-if="element.iconImageUrl !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingIconImage"
@click="handleRemoveIconImage"
>
Remove icon image
</button>
</div>
<p
v-if="iconImageStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
data-cy="admin-element-icon-image-status"
aria-live="polite"
>
{{ iconImageStatus }}
</p>
</section>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Rich Text HTML</span>
@ -150,15 +273,56 @@ async function handleSubmit(): Promise<void> {
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">PDF Path</span>
<section class="admin-element-page__media-field">
<span class="admin-element-page__label">PDF</span>
<a
v-if="element.pdfPath !== null"
:href="element.pdfPath"
class="admin-element-page__pdf-link"
data-cy="admin-element-current-pdf"
target="_blank"
rel="noreferrer"
>
View current PDF
</a>
<p v-else class="admin-element-page__empty-media">No PDF</p>
<div class="admin-element-page__media-actions">
<input
v-model="form.pdfPath"
class="admin-element-page__input"
data-cy="admin-element-pdf-path"
type="text"
ref="pdfInput"
class="admin-element-page__file-input"
data-cy="admin-element-pdf-input"
type="file"
accept="application/pdf"
:disabled="isUploadingPdf"
@change="handlePdfChange"
/>
</label>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingPdf"
@click="choosePdf"
>
{{ isUploadingPdf ? 'Uploading...' : 'Choose PDF' }}
</button>
<button
v-if="element.pdfPath !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingPdf"
@click="handleRemovePdf"
>
Remove PDF
</button>
</div>
<p
v-if="pdfStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
data-cy="admin-element-pdf-status"
aria-live="polite"
>
{{ pdfStatus }}
</p>
</section>
<label class="admin-element-page__field">
<span class="admin-element-page__label">YouTube URL</span>
@ -234,6 +398,16 @@ async function handleSubmit(): Promise<void> {
gap: 0.35rem;
}
.admin-element-page__media-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.7rem;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.admin-element-page__label {
color: var(--color-text-muted);
font-size: 0.9rem;
@ -267,6 +441,61 @@ async function handleSubmit(): Promise<void> {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.admin-element-page__icon-preview {
width: 6rem;
height: 6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
object-fit: contain;
}
.admin-element-page__empty-media {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.admin-element-page__media-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.admin-element-page__file-input {
display: none;
}
.admin-element-page__secondary-button,
.admin-element-page__pdf-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 0.95rem;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
background: var(--color-white);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
}
.admin-element-page__secondary-button {
cursor: pointer;
}
.admin-element-page__secondary-button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.admin-element-page__secondary-button:hover:not(:disabled),
.admin-element-page__secondary-button:focus-visible,
.admin-element-page__pdf-link:hover,
.admin-element-page__pdf-link:focus-visible {
border-color: var(--color-slate);
}
.admin-element-page__actions {
display: flex;
align-items: center;
@ -314,5 +543,11 @@ async function handleSubmit(): Promise<void> {
align-items: stretch;
flex-direction: column;
}
.admin-element-page__media-actions {
align-items: stretch;
flex-direction: column;
width: 100%;
}
}
</style>