diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index 37b846f..a3c89f6 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -4,10 +4,15 @@ namespace App\Controllers; use App\Element\ElementRepository; use App\Exceptions\BadRequestException; +use App\Exceptions\NotFoundException; use App\Set\Set as DomainSet; use App\Set\SetRepository; use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot; use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest; +use App\Set\UseCases\DeleteSet\DeleteSet; +use App\Set\UseCases\DeleteSet\DeleteSetRequest; +use App\Set\UseCases\UpdateSet\UpdateSet; +use App\Set\UseCases\UpdateSet\UpdateSetRequest; use App\Shared\Files\Filesystem; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -19,6 +24,8 @@ class SetController private SetRepository $setRepository, private ElementRepository $elementRepository, private CreateSetWithRoot $createSetWithRoot, + private UpdateSet $updateSet, + private DeleteSet $deleteSet, private Filesystem $filesystem, ) { } @@ -61,6 +68,54 @@ class SetController ], 201); } + public function update(Request $request, ?int $id): JsonResponse + { + $iconImage = $this->uploadedFileInput($request, 'iconImage'); + + try { + $set = $this->updateSet->execute( + new UpdateSetRequest( + id: $id, + name: $this->stringInput($request, 'name'), + description: $this->stringInput($request, 'description'), + iconImageContents: $iconImage['contents'], + iconImageOriginalName: $iconImage['originalName'], + iconImageMimeType: $iconImage['mimeType'], + iconImageSizeBytes: $iconImage['sizeBytes'], + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'set' => $this->buildSetPayload($set), + ], 200); + } + + public function delete(?int $id): JsonResponse + { + try { + $this->deleteSet->execute(new DeleteSetRequest(id: $id)); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse(null, 204); + } + /** * @return array{ * id: int, diff --git a/backend/app/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index d7db606..be7ac1d 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -2,6 +2,8 @@ namespace App\Set; +use DomainException; + class EloquentSetRepository implements SetRepository { public function create(CreateSetDto $dto): Set @@ -15,6 +17,35 @@ class EloquentSetRepository implements SetRepository return $this->toDomain($model); } + public function update(Set $set): Set + { + $model = SetModel::find($set->getId()); + if ($model === null) { + throw new DomainException( + "Set with id: {$set->getId()} doesnt exist" + ); + } + + $model->name = $set->getName(); + $model->description = $set->getDescription(); + $model->icon_image_url = $set->getIconImageUrl(); + $model->save(); + + return $this->toDomain($model); + } + + public function delete(Set $set): void + { + $model = SetModel::find($set->getId()); + if ($model === null) { + throw new DomainException( + "Set with id: {$set->getId()} doesnt exist" + ); + } + + $model->delete(); + } + public function find(int $id): ?Set { $model = SetModel::find($id); diff --git a/backend/app/Set/Set.php b/backend/app/Set/Set.php index bf44461..1557ed4 100644 --- a/backend/app/Set/Set.php +++ b/backend/app/Set/Set.php @@ -22,13 +22,28 @@ class Set return $this->name; } + public function setName(string $name): void + { + $this->name = $name; + } + public function getDescription(): string { return $this->description; } + public function setDescription(string $description): void + { + $this->description = $description; + } + public function getIconImageUrl(): string { return $this->iconImageUrl; } + + public function setIconImageUrl(string $iconImageUrl): void + { + $this->iconImageUrl = $iconImageUrl; + } } diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 7f1efcd..79d9dde 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -6,6 +6,10 @@ interface SetRepository { public function create(CreateSetDto $dto): Set; + public function update(Set $set): Set; + + public function delete(Set $set): void; + public function find(int $id): ?Set; /** diff --git a/backend/app/Set/UseCases/DeleteSet/DeleteSet.php b/backend/app/Set/UseCases/DeleteSet/DeleteSet.php new file mode 100644 index 0000000..9ff51a7 --- /dev/null +++ b/backend/app/Set/UseCases/DeleteSet/DeleteSet.php @@ -0,0 +1,115 @@ + + */ + private array $managedFilePaths = []; + + public function __construct( + private SetRepository $setRepository, + private ElementRepository $elementRepository, + private Filesystem $filesystem, + ) { + } + + /** + * @throws BadRequestException + * @throws NotFoundException + */ + public function execute(DeleteSetRequest $request): void + { + if ($request->id === null) { + throw new BadRequestException('setId is required'); + } + + $set = $this->setRepository->find($request->id); + if ($set === null) { + throw new NotFoundException('Set not found'); + } + + $this->managedFilePaths = []; + $this->rememberManagedFile($set->getIconImageUrl()); + + $elements = $this->elementRepository->findBySet($set); + foreach ($elements as $element) { + $this->rememberManagedFile($element->getIconImageUrl()); + $this->rememberManagedFile($element->getShortPdfPath()); + $this->rememberManagedFile($element->getLongPdfPath()); + } + + foreach ($this->deepestElementsFirst($elements) as $element) { + $this->elementRepository->delete($element); + } + + $this->setRepository->delete($set); + foreach (array_keys($this->managedFilePaths) as $path) { + $this->filesystem->delete($path); + } + } + + /** + * @param Element[] $elements + * @return Element[] + */ + private function deepestElementsFirst(array $elements): array + { + usort($elements, function ( + Element $firstElement, + Element $secondElement, + ): int { + $firstDepth = $this->elementDepth($firstElement); + $secondDepth = $this->elementDepth($secondElement); + if ($firstDepth === $secondDepth) { + return $secondElement->getId() <=> $firstElement->getId(); + } + + return $secondDepth <=> $firstDepth; + }); + + return $elements; + } + + private function elementDepth(Element $element): int + { + $depth = 0; + $parentElement = $element->getParentElement(); + while ($parentElement !== null) { + $depth++; + $parentElement = $parentElement->getParentElement(); + } + + return $depth; + } + + private function rememberManagedFile(?string $path): void + { + if ($path === null) { + return; + } + + if (! $this->isManagedFilePath($path)) { + return; + } + + $this->managedFilePaths[$path] = true; + } + + private function isManagedFilePath(string $path): bool + { + return str_starts_with($path, 'set-icons/') + || str_starts_with($path, 'element-icons/') + || str_starts_with($path, 'element-pdfs/short/') + || str_starts_with($path, 'element-pdfs/long/'); + } +} diff --git a/backend/app/Set/UseCases/DeleteSet/DeleteSetRequest.php b/backend/app/Set/UseCases/DeleteSet/DeleteSetRequest.php new file mode 100644 index 0000000..5954a0c --- /dev/null +++ b/backend/app/Set/UseCases/DeleteSet/DeleteSetRequest.php @@ -0,0 +1,10 @@ +id === null) { + throw new BadRequestException('setId is required'); + } + + if ( + $request->description === null + || trim($request->description) === '' + ) { + throw new BadRequestException('description is required'); + } + + $set = $this->setRepository->find($request->id); + if ($set === null) { + throw new NotFoundException('Set not found'); + } + + $set->setDescription($request->description); + + return $this->setRepository->update($set); + } +} diff --git a/backend/app/Set/UseCases/UpdateSet/UpdateDescriptionRequest.php b/backend/app/Set/UseCases/UpdateSet/UpdateDescriptionRequest.php new file mode 100644 index 0000000..1f975f0 --- /dev/null +++ b/backend/app/Set/UseCases/UpdateSet/UpdateDescriptionRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('setId is required'); + } + + if ( + $request->fileContents === null + || $request->fileOriginalName === null + || $request->fileMimeType === null + || $request->fileSizeBytes === null + ) { + throw new BadRequestException('icon image is required'); + } + + if ( + ! in_array( + $request->fileMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException( + 'icon image must be a jpeg, png or webp image', + ); + } + + if ($request->fileSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException( + 'icon image must be 5MB or smaller', + ); + } + + $set = $this->setRepository->find($request->id); + if ($set === null) { + throw new NotFoundException('Set not found'); + } + + $oldIconImageUrl = $set->getIconImageUrl(); + $iconImage = new FileToUpload( + contents: $request->fileContents, + originalName: $request->fileOriginalName, + mimeType: $request->fileMimeType, + sizeBytes: $request->fileSizeBytes, + ); + $path = $this->filesystem->upload($iconImage, 'set-icons'); + $set->setIconImageUrl($path); + $updatedSet = $this->setRepository->update($set); + $this->deleteManagedSetIcon($oldIconImageUrl); + + return $updatedSet; + } + + private function deleteManagedSetIcon(string $path): void + { + if (! str_starts_with($path, 'set-icons/')) { + return; + } + + $this->filesystem->delete($path); + } +} diff --git a/backend/app/Set/UseCases/UpdateSet/UpdateIconImageRequest.php b/backend/app/Set/UseCases/UpdateSet/UpdateIconImageRequest.php new file mode 100644 index 0000000..33f7df3 --- /dev/null +++ b/backend/app/Set/UseCases/UpdateSet/UpdateIconImageRequest.php @@ -0,0 +1,15 @@ +id === null) { + throw new BadRequestException('setId is required'); + } + + if ($request->name === null || trim($request->name) === '') { + throw new BadRequestException('name is required'); + } + + $set = $this->setRepository->find($request->id); + if ($set === null) { + throw new NotFoundException('Set not found'); + } + + $set->setName(trim($request->name)); + + return $this->setRepository->update($set); + } +} diff --git a/backend/app/Set/UseCases/UpdateSet/UpdateNameRequest.php b/backend/app/Set/UseCases/UpdateSet/UpdateNameRequest.php new file mode 100644 index 0000000..aa68a18 --- /dev/null +++ b/backend/app/Set/UseCases/UpdateSet/UpdateNameRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('setId is required'); + } + + if ($this->hasNoFields($request)) { + throw new BadRequestException('nothing to update'); + } + + if ($request->name !== null) { + $this->updateName->execute(new UpdateNameRequest( + id: $request->id, + name: $request->name, + )); + } + if ($request->description !== null) { + $this->updateDescription->execute(new UpdateDescriptionRequest( + id: $request->id, + description: $request->description, + )); + } + if ($this->hasIconImageInput($request)) { + $this->updateIconImage->execute(new UpdateIconImageRequest( + id: $request->id, + fileContents: $request->iconImageContents, + fileOriginalName: $request->iconImageOriginalName, + fileMimeType: $request->iconImageMimeType, + fileSizeBytes: $request->iconImageSizeBytes, + )); + } + + $set = $this->setRepository->find($request->id); + if ($set === null) { + throw new NotFoundException('Set not found'); + } + + return $set; + } + + private function hasNoFields(UpdateSetRequest $request): bool + { + return $request->name === null + && $request->description === null + && ! $this->hasIconImageInput($request); + } + + private function hasIconImageInput(UpdateSetRequest $request): bool + { + return $request->iconImageContents !== null + || $request->iconImageOriginalName !== null + || $request->iconImageMimeType !== null + || $request->iconImageSizeBytes !== null; + } +} diff --git a/backend/app/Set/UseCases/UpdateSet/UpdateSetRequest.php b/backend/app/Set/UseCases/UpdateSet/UpdateSetRequest.php new file mode 100644 index 0000000..b6b3cb9 --- /dev/null +++ b/backend/app/Set/UseCases/UpdateSet/UpdateSetRequest.php @@ -0,0 +1,17 @@ +middleware(AuthMiddleware::class); +Route::post('/sets/{id}/update', [SetController::class, 'update']) + ->middleware(AuthMiddleware::class); +Route::delete('/sets/{id}', [SetController::class, 'delete']) + ->middleware(AuthMiddleware::class); Route::get('/elements/{id}', [ElementController::class, 'show']); Route::get('/element-pdfs/{folder}/{fileName}', [ ElementController::class, diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index 079ea93..f3efc1c 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -27,6 +27,19 @@ class FakeSetRepository implements SetRepository return $set; } + public function update(DomainSet $set): DomainSet + { + $updatedSet = $this->cloneSet($set); + $this->setsById[$updatedSet->getId()] = $updatedSet; + + return $this->cloneSet($updatedSet); + } + + public function delete(DomainSet $set): void + { + unset($this->setsById[$set->getId()]); + } + public function find(int $id): ?DomainSet { if (! isset($this->setsById[$id])) { diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index 8f4af23..b2afe90 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -140,6 +140,153 @@ class SetsEndpointTest extends TestCase ); } + public function testUpdateSetRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: '/assets/original-icon.png', + )); + + $response = $this->postJson( + "/api/sets/{$set->getId()}/update", + [ + 'name' => 'Updated Set', + 'description' => 'Updated set description', + ], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedUpdateSetUpdatesMediaPayloadOnly(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $this->createSession('valid-token'); + $set = $setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: '/assets/original-icon.png', + )); + $rootElement = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Original Root', + description: 'Original root description', + iconImageUrl: '/assets/original-icon.png', + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->postJson("/api/sets/{$set->getId()}/update", [ + 'name' => 'Updated Set', + 'description' => 'Updated set description', + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Updated Set', + 'description' => 'Updated set description', + 'iconImageUrl' => '/assets/original-icon.png', + 'rootElementId' => $rootElement->getId(), + ], + ]); + + $updatedSet = $setRepository->find($set->getId()); + $this->assertNotNull($updatedSet); + $this->assertSame('Updated Set', $updatedSet->getName()); + $this->assertSame( + 'Updated set description', + $updatedSet->getDescription(), + ); + + $unchangedRootElement = $elementRepository->find( + $rootElement->getId(), + ); + $this->assertNotNull($unchangedRootElement); + $this->assertSame('Original Root', $unchangedRootElement->getTitle()); + $this->assertSame( + 'Original root description', + $unchangedRootElement->getDescription(), + ); + } + + public function testDeleteSetRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Set to delete', + description: 'Set deletion description', + iconImageUrl: '/assets/delete-icon.png', + )); + + $response = $this->deleteJson("/api/sets/{$set->getId()}"); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedDeleteSetRemovesSetAndElements(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $this->createSession('valid-token'); + $set = $setRepository->create(new CreateSetDto( + name: 'Set to delete', + description: 'Set deletion description', + iconImageUrl: 'set-icons/delete-icon.png', + )); + $rootElement = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Root to delete', + description: 'Root deletion description', + iconImageUrl: 'set-icons/delete-icon.png', + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + $childElement = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Child to delete', + description: 'Child deletion description', + iconImageUrl: 'element-icons/delete-child.png', + richText: '', + shortPdfPath: 'element-pdfs/short/delete-child.pdf', + longPdfPath: 'element-pdfs/long/delete-child.pdf', + youtubeUrl: null, + parentElement: $rootElement, + )); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->deleteJson("/api/sets/{$set->getId()}"); + + $response->assertNoContent(); + $this->assertNull($setRepository->find($set->getId())); + $this->assertNull($elementRepository->find($rootElement->getId())); + $this->assertNull($elementRepository->find($childElement->getId())); + $setsResponse = $this->getJson('/api/sets'); + $setsResponse->assertOk(); + $setsResponse->assertJsonMissing([ + 'id' => $set->getId(), + ]); + } + public function testCreateSetRequiresIconImage(): void { $this->createSession('valid-token'); diff --git a/backend/tests/Unit/Set/UseCases/DeleteSetTest.php b/backend/tests/Unit/Set/UseCases/DeleteSetTest.php new file mode 100644 index 0000000..6c7272f --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/DeleteSetTest.php @@ -0,0 +1,140 @@ +setRepository = new FakeSetRepository(); + $this->elementRepository = new FakeElementRepository(); + $this->filesystem = new FakeFilesystem(); + $this->deleteSet = new DeleteSet( + $this->setRepository, + $this->elementRepository, + $this->filesystem, + ); + } + + public function testDeletesSetElementTreeAndManagedFiles(): void + { + $set = $this->createSet('set-icons/set.png'); + $rootElement = $this->createElement( + set: $set, + parentElement: null, + title: 'Root', + iconImageUrl: 'set-icons/set.png', + shortPdfPath: null, + longPdfPath: null, + ); + $childElement = $this->createElement( + set: $set, + parentElement: $rootElement, + title: 'Child', + iconImageUrl: 'element-icons/child.png', + shortPdfPath: 'element-pdfs/short/child.pdf', + longPdfPath: 'element-pdfs/long/child.pdf', + ); + $grandchildElement = $this->createElement( + set: $set, + parentElement: $childElement, + title: 'Grandchild', + iconImageUrl: 'element-icons/grandchild.png', + shortPdfPath: 'element-pdfs/short/grandchild.pdf', + longPdfPath: 'element-pdfs/long/grandchild.pdf', + ); + + $this->deleteSet->execute(new DeleteSetRequest(id: $set->getId())); + + $this->assertNull($this->setRepository->find($set->getId())); + $this->assertNull( + $this->elementRepository->find($rootElement->getId()), + ); + $this->assertNull( + $this->elementRepository->find($childElement->getId()), + ); + $this->assertNull( + $this->elementRepository->find($grandchildElement->getId()), + ); + $this->assertEqualsCanonicalizing([ + 'set-icons/set.png', + 'element-icons/child.png', + 'element-pdfs/short/child.pdf', + 'element-pdfs/long/child.pdf', + 'element-icons/grandchild.png', + 'element-pdfs/short/grandchild.pdf', + 'element-pdfs/long/grandchild.pdf', + ], $this->filesystem->deletedPaths); + $this->assertSame( + count($this->filesystem->deletedPaths), + count(array_unique($this->filesystem->deletedPaths)), + ); + } + + public function testRequiresSetId(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('setId is required'); + + $this->deleteSet->execute(new DeleteSetRequest(id: null)); + } + + public function testThrowsWhenSetIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Set not found'); + + $this->deleteSet->execute(new DeleteSetRequest(id: 999)); + } + + private function createSet(string $iconImageUrl): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: 'Set', + description: 'Set description', + iconImageUrl: $iconImageUrl, + )); + } + + private function createElement( + DomainSet $set, + ?Element $parentElement, + string $title, + ?string $iconImageUrl, + ?string $shortPdfPath, + ?string $longPdfPath, + ): Element { + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: $title, + description: "$title description", + iconImageUrl: $iconImageUrl, + richText: '', + shortPdfPath: $shortPdfPath, + longPdfPath: $longPdfPath, + youtubeUrl: null, + parentElement: $parentElement, + )); + } +} diff --git a/backend/tests/Unit/Set/UseCases/UpdateDescriptionTest.php b/backend/tests/Unit/Set/UseCases/UpdateDescriptionTest.php new file mode 100644 index 0000000..ae19d88 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/UpdateDescriptionTest.php @@ -0,0 +1,67 @@ +setRepository = new FakeSetRepository(); + } + + public function testUpdatesOnlyDescription(): void + { + $set = $this->createSet(); + $updateDescription = new UpdateDescription($this->setRepository); + + $updatedSet = $updateDescription->execute( + new UpdateDescriptionRequest( + id: $set->getId(), + description: 'Updated set description', + ) + ); + + $this->assertSame( + 'Updated set description', + $updatedSet->getDescription(), + ); + $this->assertSame($set->getName(), $updatedSet->getName()); + $this->assertSame( + $set->getIconImageUrl(), + $updatedSet->getIconImageUrl(), + ); + } + + public function testRejectsBlankDescription(): void + { + $set = $this->createSet(); + $updateDescription = new UpdateDescription($this->setRepository); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('description is required'); + + $updateDescription->execute(new UpdateDescriptionRequest( + id: $set->getId(), + description: '', + )); + } + + private function createSet(): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + } +} diff --git a/backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php b/backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php new file mode 100644 index 0000000..4ff30f5 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php @@ -0,0 +1,108 @@ +setRepository = new FakeSetRepository(); + $this->filesystem = new FakeFilesystem(); + } + + public function testUpdatesOnlyIconAndDeletesOldIcon(): void + { + $set = $this->createSet(); + $updateIconImage = new UpdateIconImage( + $this->setRepository, + $this->filesystem, + ); + + $updatedSet = $updateIconImage->execute(new UpdateIconImageRequest( + id: $set->getId(), + fileContents: 'image contents', + fileOriginalName: 'updated.png', + fileMimeType: 'image/png', + fileSizeBytes: 13, + )); + + $this->assertSame($set->getName(), $updatedSet->getName()); + $this->assertSame( + $set->getDescription(), + $updatedSet->getDescription(), + ); + $this->assertSame( + 'set-icons/fake-updated.png', + $updatedSet->getIconImageUrl(), + ); + $this->assertCount(1, $this->filesystem->uploads); + $this->assertSame('set-icons', $this->filesystem->uploads[0]['folder']); + $this->assertSame([ + 'set-icons/original.png', + ], $this->filesystem->deletedPaths); + } + + public function testRejectsMissingFile(): void + { + $set = $this->createSet(); + $updateIconImage = new UpdateIconImage( + $this->setRepository, + $this->filesystem, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image is required'); + + $updateIconImage->execute(new UpdateIconImageRequest( + id: $set->getId(), + fileContents: null, + fileOriginalName: null, + fileMimeType: null, + fileSizeBytes: null, + )); + } + + public function testRejectsInvalidMimeType(): void + { + $set = $this->createSet(); + $updateIconImage = new UpdateIconImage( + $this->setRepository, + $this->filesystem, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'icon image must be a jpeg, png or webp image', + ); + + $updateIconImage->execute(new UpdateIconImageRequest( + id: $set->getId(), + fileContents: 'image contents', + fileOriginalName: 'updated.gif', + fileMimeType: 'image/gif', + fileSizeBytes: 13, + )); + } + + private function createSet(): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + } +} diff --git a/backend/tests/Unit/Set/UseCases/UpdateNameTest.php b/backend/tests/Unit/Set/UseCases/UpdateNameTest.php new file mode 100644 index 0000000..342cb38 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/UpdateNameTest.php @@ -0,0 +1,65 @@ +setRepository = new FakeSetRepository(); + } + + public function testUpdatesOnlyName(): void + { + $set = $this->createSet(); + $updateName = new UpdateName($this->setRepository); + + $updatedSet = $updateName->execute(new UpdateNameRequest( + id: $set->getId(), + name: 'Updated Set', + )); + + $this->assertSame('Updated Set', $updatedSet->getName()); + $this->assertSame( + $set->getDescription(), + $updatedSet->getDescription(), + ); + $this->assertSame( + $set->getIconImageUrl(), + $updatedSet->getIconImageUrl(), + ); + } + + public function testRejectsBlankName(): void + { + $set = $this->createSet(); + $updateName = new UpdateName($this->setRepository); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('name is required'); + + $updateName->execute(new UpdateNameRequest( + id: $set->getId(), + name: '', + )); + } + + private function createSet(): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + } +} diff --git a/backend/tests/Unit/Set/UseCases/UpdateSetTest.php b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php new file mode 100644 index 0000000..0104c61 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php @@ -0,0 +1,236 @@ +setRepository = new FakeSetRepository(); + $this->elementRepository = new FakeElementRepository(); + $this->filesystem = new FakeFilesystem(); + $this->updateSet = new UpdateSet( + new UpdateName($this->setRepository), + new UpdateDescription($this->setRepository), + new UpdateIconImage( + $this->setRepository, + $this->filesystem, + ), + $this->setRepository, + ); + } + + public function testUpdatesSetWithoutChangingRootElement(): void + { + $set = $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + $rootElement = $this->elementRepository->create(new CreateElementDto( + set: $set, + title: 'Original Root', + description: 'Original root description', + iconImageUrl: 'set-icons/original.png', + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + $updatedSet = $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: 'Updated Set', + description: 'Updated set description', + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + + $this->assertSame('Updated Set', $updatedSet->getName()); + $this->assertSame( + 'Updated set description', + $updatedSet->getDescription(), + ); + $this->assertSame( + 'set-icons/original.png', + $updatedSet->getIconImageUrl(), + ); + + $unchangedRootElement = $this->elementRepository->find( + $rootElement->getId(), + ); + $this->assertNotNull($unchangedRootElement); + $this->assertSame('Original Root', $unchangedRootElement->getTitle()); + $this->assertSame( + 'Original root description', + $unchangedRootElement->getDescription(), + ); + $this->assertSame( + 'set-icons/original.png', + $unchangedRootElement->getIconImageUrl(), + ); + } + + public function testUpdatesOnlySuppliedFields(): void + { + $set = $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + + $updatedSet = $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: 'Updated Set', + description: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + + $this->assertSame('Updated Set', $updatedSet->getName()); + $this->assertSame( + 'Original set description', + $updatedSet->getDescription(), + ); + $this->assertSame( + 'set-icons/original.png', + $updatedSet->getIconImageUrl(), + ); + } + + public function testReplacesSetIconAndDeletesOldManagedIcon(): void + { + $set = $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + + $updatedSet = $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: 'Updated Set', + description: 'Updated set description', + iconImageContents: 'image contents', + iconImageOriginalName: 'updated.png', + iconImageMimeType: 'image/png', + iconImageSizeBytes: 13, + )); + + $this->assertSame( + 'set-icons/fake-updated.png', + $updatedSet->getIconImageUrl(), + ); + $this->assertCount(1, $this->filesystem->uploads); + $this->assertSame('set-icons', $this->filesystem->uploads[0]['folder']); + $this->assertSame([ + 'set-icons/original.png', + ], $this->filesystem->deletedPaths); + } + + public function testRejectsNothingToUpdate(): void + { + $set = $this->createSet(); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('nothing to update'); + + $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: null, + description: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + } + + public function testRequiresName(): void + { + $set = $this->createSet(); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('name is required'); + + $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: '', + description: null, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + } + + public function testRequiresDescription(): void + { + $set = $this->createSet(); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('description is required'); + + $this->updateSet->execute(new UpdateSetRequest( + id: $set->getId(), + name: null, + description: '', + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + } + + public function testThrowsWhenSetIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Set not found'); + + $this->updateSet->execute(new UpdateSetRequest( + id: 999, + name: 'Updated Set', + description: 'Updated set description', + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + )); + } + + private function createSet(): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: 'Original Set', + description: 'Original set description', + iconImageUrl: 'set-icons/original.png', + )); + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 9aca794..46d81a1 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -181,6 +181,13 @@ describe('media page sets', () => { cy.get('[data-cy="media-create-set-card"]').should('not.exist') }) + it('does not show set management actions to logged-out users', () => { + cy.visit('/media') + + cy.get('[data-cy="media-set-edit"]').should('not.exist') + cy.get('[data-cy="media-set-delete"]').should('not.exist') + }) + it('creates a set from the logged-in media page modal', () => { loginAsAdmin() cy.visit('/media') @@ -205,4 +212,67 @@ describe('media page sets', () => { cy.get('[data-cy="admin-element-description"]') .should('have.value', 'Created through the media page modal') }) + + it('edits a set from the logged-in media page modal', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/media') + cy.intercept('POST', /\/api\/sets\/1\/update$/).as('updateSet') + + cy.contains('[data-cy="media-set-card"]', 'Baderech HaAvodah') + .within(() => { + cy.get('[data-cy="media-set-edit"]').click() + }) + cy.get('[data-cy="edit-set-modal"]').should('be.visible') + cy.get('[data-cy="edit-set-name"]') + .clear() + .type('Edited Cypress Set') + cy.get('[data-cy="edit-set-description"]') + .clear() + .type('Edited through the set modal') + cy.get('[data-cy="edit-set-submit"]').click() + cy.wait('@updateSet') + + cy.get('[data-cy="edit-set-modal"]').should('not.exist') + cy.contains('[data-cy="media-set-card"]', 'Edited Cypress Set') + .should('be.visible') + .and('contain.text', 'Edited through the set modal') + .click() + cy.location('pathname').should('eq', '/element/1') + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + + cy.resetDb() + }) + + it('warns before deleting a set from the logged-in media page', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/media') + cy.intercept('DELETE', /\/api\/sets\/2$/).as('deleteSet') + + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .within(() => { + cy.get('[data-cy="media-set-delete"]').click() + }) + cy.get('[data-cy="delete-set-modal"]') + .should('be.visible') + .and('contain.text', 'Daily Learning') + .and('contain.text', 'all elements and uploaded files') + cy.get('[data-cy="delete-set-cancel"]').click() + cy.get('[data-cy="delete-set-modal"]').should('not.exist') + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .should('be.visible') + + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .within(() => { + cy.get('[data-cy="media-set-delete"]').click() + }) + cy.get('[data-cy="delete-set-confirm"]').click() + cy.wait('@deleteSet') + cy.get('[data-cy="delete-set-modal"]').should('not.exist') + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .should('not.exist') + + cy.resetDb() + }) }) diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts index e869470..e669e85 100644 --- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -15,11 +15,18 @@ export interface CreateMediaSetInput { iconImage: File } +export interface UpdateMediaSetInput { + id: number + name: string + description: string + iconImage: File | null +} + interface SetsResponse { sets: MediaSet[] } -interface CreateSetResponse { +interface SetResponse { set: MediaSet } @@ -34,7 +41,11 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { const isLoading = ref(false) const error = ref(null) const isCreating = ref(false) + const isUpdating = ref(false) + const isDeleting = ref(false) const createError = ref(null) + const updateError = ref(null) + const deleteError = ref(null) async function fetchSets(): Promise { error.value = null @@ -83,7 +94,7 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { return null } - const data: CreateSetResponse = await response.json() + const data: SetResponse = await response.json() sets.value = [...sets.value, data.set] return data.set @@ -95,13 +106,112 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { } } + async function updateSet( + input: UpdateMediaSetInput, + ): Promise { + updateError.value = null + isUpdating.value = true + + const formData = new FormData() + formData.append('name', input.name) + formData.append('description', input.description) + if (input.iconImage !== null) { + formData.append('iconImage', input.iconImage) + } + + try { + const encodedSetId = encodeURIComponent(input.id.toString()) + const response = await fetch( + `${API_BASE_URL}/api/sets/${encodedSetId}/update`, + { + method: 'POST', + credentials: 'include', + body: formData, + }, + ) + + if (!response.ok) { + updateError.value = await errorMessage( + response, + 'Could not update media set', + ) + return null + } + + const data: SetResponse = await response.json() + sets.value = sets.value.map((mediaSet) => { + if (mediaSet.id === data.set.id) { + return data.set + } + + return mediaSet + }) + + return data.set + } catch { + updateError.value = 'Network error - could not update media set' + return null + } finally { + isUpdating.value = false + } + } + + async function deleteSet(setId: number): Promise { + deleteError.value = null + isDeleting.value = true + + try { + const encodedSetId = encodeURIComponent(setId.toString()) + const response = await fetch(`${API_BASE_URL}/api/sets/${encodedSetId}`, { + method: 'DELETE', + credentials: 'include', + }) + + if (!response.ok) { + deleteError.value = await errorMessage( + response, + 'Could not delete media set', + ) + return false + } + + sets.value = sets.value.filter((mediaSet) => { + return mediaSet.id !== setId + }) + return true + } catch { + deleteError.value = 'Network error - could not delete media set' + return false + } finally { + isDeleting.value = false + } + } + + async function errorMessage( + response: Response, + fallbackMessage: string, + ): Promise { + try { + const data: ErrorResponse = await response.json() + return data.error ?? fallbackMessage + } catch { + return fallbackMessage + } + } + return { sets, isLoading, error, isCreating, + isUpdating, + isDeleting, createError, + updateError, + deleteError, fetchSets, createSet, + updateSet, + deleteSet, } }) diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index 513a329..07f407b 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -1,11 +1,17 @@