{{ mediaSet.name }}
++ {{ mediaSet.description }} +
+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
+ {{ mediaSet.description }} +
++ Delete "{{ deletingSet.name }}"? This will permanently delete this + set and all elements and uploaded files inside it. +
++ {{ deleteError }} +
+ +