From f8e1ef1397f5706b65d4f8f0ed02c81f6d0012aa Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:08:30 +0300
Subject: [PATCH 1/9] test set edits
---
backend/tests/Feature/SetsEndpointTest.php | 82 ++++++++
.../tests/Unit/Set/UseCases/UpdateSetTest.php | 181 ++++++++++++++++++
2 files changed, 263 insertions(+)
create mode 100644 backend/tests/Unit/Set/UseCases/UpdateSetTest.php
diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php
index 8f4af23..54a730a 100644
--- a/backend/tests/Feature/SetsEndpointTest.php
+++ b/backend/tests/Feature/SetsEndpointTest.php
@@ -140,6 +140,88 @@ 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 testCreateSetRequiresIconImage(): void
{
$this->createSession('valid-token');
diff --git a/backend/tests/Unit/Set/UseCases/UpdateSetTest.php b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php
new file mode 100644
index 0000000..a1bba41
--- /dev/null
+++ b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php
@@ -0,0 +1,181 @@
+setRepository = new FakeSetRepository();
+ $this->elementRepository = new FakeElementRepository();
+ $this->filesystem = new FakeFilesystem();
+ $this->updateSet = new UpdateSet(
+ $this->setRepository,
+ $this->filesystem,
+ );
+ }
+
+ 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 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 testRequiresName(): void
+ {
+ $set = $this->createSet();
+
+ $this->expectException(BadRequestException::class);
+ $this->expectExceptionMessage('name is required');
+
+ $this->updateSet->execute(new UpdateSetRequest(
+ id: $set->getId(),
+ name: '',
+ description: 'Updated set description',
+ 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: 'Updated Set',
+ 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',
+ ));
+ }
+}
From 53d4120e8305ee29b936d2f88c7ecf2acd155d43 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:10:23 +0300
Subject: [PATCH 2/9] add set edits
---
backend/app/Controllers/SetController.php | 35 +++++
backend/app/Set/EloquentSetRepository.php | 31 +++++
backend/app/Set/Set.php | 15 ++
backend/app/Set/SetRepository.php | 4 +
.../app/Set/UseCases/UpdateSet/UpdateSet.php | 128 ++++++++++++++++++
.../UseCases/UpdateSet/UpdateSetRequest.php | 17 +++
backend/routes/api.php | 2 +
backend/tests/Fakes/FakeSetRepository.php | 13 ++
8 files changed, 245 insertions(+)
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateSet.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateSetRequest.php
diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php
index 37b846f..239963f 100644
--- a/backend/app/Controllers/SetController.php
+++ b/backend/app/Controllers/SetController.php
@@ -4,10 +4,13 @@ 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\UpdateSet\UpdateSet;
+use App\Set\UseCases\UpdateSet\UpdateSetRequest;
use App\Shared\Files\Filesystem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -19,6 +22,7 @@ class SetController
private SetRepository $setRepository,
private ElementRepository $elementRepository,
private CreateSetWithRoot $createSetWithRoot,
+ private UpdateSet $updateSet,
private Filesystem $filesystem,
) {
}
@@ -61,6 +65,37 @@ 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);
+ }
+
/**
* @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/UpdateSet/UpdateSet.php b/backend/app/Set/UseCases/UpdateSet/UpdateSet.php
new file mode 100644
index 0000000..19c6bc4
--- /dev/null
+++ b/backend/app/Set/UseCases/UpdateSet/UpdateSet.php
@@ -0,0 +1,128 @@
+id === null) {
+ throw new BadRequestException('setId is required');
+ }
+ if ($request->name === null || $request->name === '') {
+ throw new BadRequestException('name is required');
+ }
+ if (
+ $request->description === null
+ || $request->description === ''
+ ) {
+ throw new BadRequestException('description is required');
+ }
+
+ $set = $this->setRepository->find($request->id);
+ if ($set === null) {
+ throw new NotFoundException('Set not found');
+ }
+
+ $oldIconImageUrl = $set->getIconImageUrl();
+ $newIconImageUrl = $this->iconImageUrl($request, $oldIconImageUrl);
+
+ $set->setName($request->name);
+ $set->setDescription($request->description);
+ $set->setIconImageUrl($newIconImageUrl);
+
+ $updatedSet = $this->setRepository->update($set);
+ if ($newIconImageUrl !== $oldIconImageUrl) {
+ $this->deleteManagedSetIcon($oldIconImageUrl);
+ }
+
+ return $updatedSet;
+ }
+
+ /**
+ * @throws BadRequestException
+ */
+ private function iconImageUrl(
+ UpdateSetRequest $request,
+ string $currentIconImageUrl,
+ ): string {
+ if (
+ $request->iconImageContents === null
+ && $request->iconImageOriginalName === null
+ && $request->iconImageMimeType === null
+ && $request->iconImageSizeBytes === null
+ ) {
+ return $currentIconImageUrl;
+ }
+
+ 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',
+ );
+ }
+
+ $iconImage = new FileToUpload(
+ contents: $request->iconImageContents,
+ originalName: $request->iconImageOriginalName,
+ mimeType: $request->iconImageMimeType,
+ sizeBytes: $request->iconImageSizeBytes,
+ );
+
+ return $this->filesystem->upload($iconImage, 'set-icons');
+ }
+
+ 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/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::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])) {
From ae48bdf93bb283c4d7111befa08fa8e545503c14 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:11:50 +0300
Subject: [PATCH 3/9] test set deletion
---
backend/tests/Feature/SetsEndpointTest.php | 65 ++++++++
.../tests/Unit/Set/UseCases/DeleteSetTest.php | 140 ++++++++++++++++++
2 files changed, 205 insertions(+)
create mode 100644 backend/tests/Unit/Set/UseCases/DeleteSetTest.php
diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php
index 54a730a..b2afe90 100644
--- a/backend/tests/Feature/SetsEndpointTest.php
+++ b/backend/tests/Feature/SetsEndpointTest.php
@@ -222,6 +222,71 @@ class SetsEndpointTest extends TestCase
);
}
+ 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,
+ ));
+ }
+}
From fb88bbfaa951173bcbf00ac0b12b4177689ae5e8 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:13:11 +0300
Subject: [PATCH 4/9] add set deletion
---
backend/app/Controllers/SetController.php | 20 +++
.../app/Set/UseCases/DeleteSet/DeleteSet.php | 115 ++++++++++++++++++
.../UseCases/DeleteSet/DeleteSetRequest.php | 10 ++
backend/routes/api.php | 2 +
4 files changed, 147 insertions(+)
create mode 100644 backend/app/Set/UseCases/DeleteSet/DeleteSet.php
create mode 100644 backend/app/Set/UseCases/DeleteSet/DeleteSetRequest.php
diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php
index 239963f..a3c89f6 100644
--- a/backend/app/Controllers/SetController.php
+++ b/backend/app/Controllers/SetController.php
@@ -9,6 +9,8 @@ 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;
@@ -23,6 +25,7 @@ class SetController
private ElementRepository $elementRepository,
private CreateSetWithRoot $createSetWithRoot,
private UpdateSet $updateSet,
+ private DeleteSet $deleteSet,
private Filesystem $filesystem,
) {
}
@@ -96,6 +99,23 @@ class SetController
], 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/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 @@
+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,
From ae6e7535befb65a43db8c59a73a83ec2a01aff54 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:15:18 +0300
Subject: [PATCH 5/9] test set controls
---
frontend/rabbi_gerzi/cypress/e2e/media.cy.ts | 70 ++++++++++++++++++++
1 file changed, 70 insertions(+)
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()
+ })
})
From f8666454c394dec2d897d2224edf92ad6064a99a Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Thu, 2 Jul 2026 17:19:19 +0300
Subject: [PATCH 6/9] add set controls
---
frontend/rabbi_gerzi/src/stores/mediaSets.ts | 114 ++++-
frontend/rabbi_gerzi/src/views/MediaPage.vue | 461 ++++++++++++++++++-
2 files changed, 568 insertions(+), 7 deletions(-)
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 @@
@@ -143,8 +303,56 @@ function resetCreateSetForm(): void {
+
+
+ {{ mediaSet.name }}
+
+ {{ mediaSet.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delete "{{ deletingSet.name }}"? This will permanently delete this
+ set and all elements and uploaded files inside it.
+
+
+
+
+ {{ deleteError }}
+
+
+
+
+
+
+
+
@@ -368,10 +732,65 @@ a.media-page__card:focus-visible {
outline-offset: 4px;
}
+.media-page__card--managed {
+ position: relative;
+ cursor: pointer;
+}
+
+.media-page__card--managed:hover:not(.media-page__card--disabled),
+.media-page__card--managed:focus-visible:not(.media-page__card--disabled) {
+ border-color: #d4ad5f;
+ box-shadow: 0 14px 35px rgb(44 44 44 / 10%);
+ transform: translateY(-2px);
+}
+
+.media-page__card--managed:focus-visible {
+ outline: 3px solid rgb(212 173 95 / 45%);
+ outline-offset: 4px;
+}
+
.media-page__card--disabled {
+ cursor: default;
opacity: 0.72;
}
+.media-page__card-actions {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ display: flex;
+ gap: 0.5rem;
+}
+
+.media-page__card-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.4rem;
+ height: 2.4rem;
+ padding: 0;
+ color: var(--color-slate);
+ background: var(--color-white);
+ border: 1px solid #e5cf9f;
+ border-radius: 9999px;
+ box-shadow: 0 8px 18px rgb(44 44 44 / 8%);
+}
+
+.media-page__card-action:hover,
+.media-page__card-action:focus-visible {
+ color: var(--color-olive);
+ border-color: #d4ad5f;
+ outline: 3px solid rgb(212 173 95 / 24%);
+ outline-offset: 2px;
+}
+
+.media-page__card-action--danger:hover,
+.media-page__card-action--danger:focus-visible {
+ color: #9f2d2d;
+ border-color: #d9a3a3;
+ outline-color: rgb(159 45 45 / 18%);
+}
+
.media-page__card-icon {
display: block;
width: 140px;
@@ -590,6 +1009,11 @@ a.media-page__card:focus-visible {
border: 1px solid var(--color-olive);
}
+.create-set-modal__submit--danger {
+ background: #9f2d2d;
+ border-color: #9f2d2d;
+}
+
.create-set-modal__secondary:disabled,
.create-set-modal__submit:disabled,
.create-set-modal__close:disabled {
@@ -597,6 +1021,33 @@ a.media-page__card:focus-visible {
opacity: 0.65;
}
+.delete-set-modal__body {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.85rem;
+ margin-top: 1.5rem;
+}
+
+.delete-set-modal__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ flex: 0 0 auto;
+ color: #9f2d2d;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 9999px;
+}
+
+.delete-set-modal__warning {
+ margin: 0;
+ color: var(--color-text);
+ font-size: 0.98rem;
+ line-height: 1.55;
+}
+
@media (max-width: 768px) {
.media-page__main {
padding: 3rem 1rem;
From b2e39a98e49bf8b72f9a19e480f1ba1cb7ad0685 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 3 Jul 2026 13:59:17 +0300
Subject: [PATCH 7/9] test set update routing
---
.../Unit/Set/UseCases/UpdateSetFieldsTest.php | 184 ++++++++++++++++++
.../tests/Unit/Set/UseCases/UpdateSetTest.php | 61 +++++-
2 files changed, 242 insertions(+), 3 deletions(-)
create mode 100644 backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php
diff --git a/backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php b/backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php
new file mode 100644
index 0000000..7ef3321
--- /dev/null
+++ b/backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php
@@ -0,0 +1,184 @@
+setRepository = new FakeSetRepository();
+ $this->filesystem = new FakeFilesystem();
+ }
+
+ public function testUpdateNameUpdatesOnlyName(): 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 testUpdateNameRejectsBlankName(): 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: '',
+ ));
+ }
+
+ public function testUpdateDescriptionUpdatesOnlyDescription(): 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 testUpdateDescriptionRejectsBlankDescription(): 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: '',
+ ));
+ }
+
+ public function testUpdateIconImageUpdatesOnlyIconAndDeletesOldIcon(): 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 testUpdateIconImageRejectsMissingFile(): 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 testUpdateIconImageRejectsInvalidMimeType(): 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/UpdateSetTest.php b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php
index a1bba41..0104c61 100644
--- a/backend/tests/Unit/Set/UseCases/UpdateSetTest.php
+++ b/backend/tests/Unit/Set/UseCases/UpdateSetTest.php
@@ -7,6 +7,9 @@ use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Set\CreateSetDto;
use App\Set\Set as DomainSet;
+use App\Set\UseCases\UpdateSet\UpdateDescription;
+use App\Set\UseCases\UpdateSet\UpdateIconImage;
+use App\Set\UseCases\UpdateSet\UpdateName;
use App\Set\UseCases\UpdateSet\UpdateSet;
use App\Set\UseCases\UpdateSet\UpdateSetRequest;
use Tests\Fakes\FakeElementRepository;
@@ -30,8 +33,13 @@ class UpdateSetTest extends TestCase
$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,
- $this->filesystem,
);
}
@@ -89,6 +97,35 @@ class UpdateSetTest extends TestCase
);
}
+ 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(
@@ -118,6 +155,24 @@ class UpdateSetTest extends TestCase
], $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();
@@ -128,7 +183,7 @@ class UpdateSetTest extends TestCase
$this->updateSet->execute(new UpdateSetRequest(
id: $set->getId(),
name: '',
- description: 'Updated set description',
+ description: null,
iconImageContents: null,
iconImageOriginalName: null,
iconImageMimeType: null,
@@ -145,7 +200,7 @@ class UpdateSetTest extends TestCase
$this->updateSet->execute(new UpdateSetRequest(
id: $set->getId(),
- name: 'Updated Set',
+ name: null,
description: '',
iconImageContents: null,
iconImageOriginalName: null,
From 742861231602d3b21f3227a782ac750cbfa52426 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 3 Jul 2026 14:02:08 +0300
Subject: [PATCH 8/9] route set updates
---
.../UseCases/UpdateSet/UpdateDescription.php | 42 ++++++
.../UpdateSet/UpdateDescriptionRequest.php | 12 ++
.../UseCases/UpdateSet/UpdateIconImage.php | 93 +++++++++++++
.../UpdateSet/UpdateIconImageRequest.php | 15 ++
.../app/Set/UseCases/UpdateSet/UpdateName.php | 39 ++++++
.../UseCases/UpdateSet/UpdateNameRequest.php | 12 ++
.../app/Set/UseCases/UpdateSet/UpdateSet.php | 128 ++++++------------
7 files changed, 252 insertions(+), 89 deletions(-)
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateDescription.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateDescriptionRequest.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateIconImage.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateIconImageRequest.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateName.php
create mode 100644 backend/app/Set/UseCases/UpdateSet/UpdateNameRequest.php
diff --git a/backend/app/Set/UseCases/UpdateSet/UpdateDescription.php b/backend/app/Set/UseCases/UpdateSet/UpdateDescription.php
new file mode 100644
index 0000000..d4ef68d
--- /dev/null
+++ b/backend/app/Set/UseCases/UpdateSet/UpdateDescription.php
@@ -0,0 +1,42 @@
+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 ($request->name === null || $request->name === '') {
- throw new BadRequestException('name is required');
+
+ if ($this->hasNoFields($request)) {
+ throw new BadRequestException('nothing to update');
}
- if (
- $request->description === null
- || $request->description === ''
- ) {
- throw new BadRequestException('description is required');
+
+ 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);
@@ -49,80 +58,21 @@ class UpdateSet
throw new NotFoundException('Set not found');
}
- $oldIconImageUrl = $set->getIconImageUrl();
- $newIconImageUrl = $this->iconImageUrl($request, $oldIconImageUrl);
-
- $set->setName($request->name);
- $set->setDescription($request->description);
- $set->setIconImageUrl($newIconImageUrl);
-
- $updatedSet = $this->setRepository->update($set);
- if ($newIconImageUrl !== $oldIconImageUrl) {
- $this->deleteManagedSetIcon($oldIconImageUrl);
- }
-
- return $updatedSet;
+ return $set;
}
- /**
- * @throws BadRequestException
- */
- private function iconImageUrl(
- UpdateSetRequest $request,
- string $currentIconImageUrl,
- ): string {
- if (
- $request->iconImageContents === null
- && $request->iconImageOriginalName === null
- && $request->iconImageMimeType === null
- && $request->iconImageSizeBytes === null
- ) {
- return $currentIconImageUrl;
- }
-
- 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',
- );
- }
-
- $iconImage = new FileToUpload(
- contents: $request->iconImageContents,
- originalName: $request->iconImageOriginalName,
- mimeType: $request->iconImageMimeType,
- sizeBytes: $request->iconImageSizeBytes,
- );
-
- return $this->filesystem->upload($iconImage, 'set-icons');
- }
-
- private function deleteManagedSetIcon(string $path): void
+ private function hasNoFields(UpdateSetRequest $request): bool
{
- if (! str_starts_with($path, 'set-icons/')) {
- return;
- }
+ return $request->name === null
+ && $request->description === null
+ && ! $this->hasIconImageInput($request);
+ }
- $this->filesystem->delete($path);
+ private function hasIconImageInput(UpdateSetRequest $request): bool
+ {
+ return $request->iconImageContents !== null
+ || $request->iconImageOriginalName !== null
+ || $request->iconImageMimeType !== null
+ || $request->iconImageSizeBytes !== null;
}
}
From 0bb17d14202925cda9466b7521364d4f16516cbb Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 3 Jul 2026 14:17:52 +0300
Subject: [PATCH 9/9] split set update tests
---
.../Set/UseCases/UpdateDescriptionTest.php | 67 +++++++++++++++
...FieldsTest.php => UpdateIconImageTest.php} | 84 +------------------
.../Unit/Set/UseCases/UpdateNameTest.php | 65 ++++++++++++++
3 files changed, 136 insertions(+), 80 deletions(-)
create mode 100644 backend/tests/Unit/Set/UseCases/UpdateDescriptionTest.php
rename backend/tests/Unit/Set/UseCases/{UpdateSetFieldsTest.php => UpdateIconImageTest.php} (53%)
create mode 100644 backend/tests/Unit/Set/UseCases/UpdateNameTest.php
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/UpdateSetFieldsTest.php b/backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php
similarity index 53%
rename from backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php
rename to backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php
index 7ef3321..4ff30f5 100644
--- a/backend/tests/Unit/Set/UseCases/UpdateSetFieldsTest.php
+++ b/backend/tests/Unit/Set/UseCases/UpdateIconImageTest.php
@@ -5,17 +5,13 @@ namespace Tests\Unit\Set\UseCases;
use App\Exceptions\BadRequestException;
use App\Set\CreateSetDto;
use App\Set\Set as DomainSet;
-use App\Set\UseCases\UpdateSet\UpdateDescription;
-use App\Set\UseCases\UpdateSet\UpdateDescriptionRequest;
use App\Set\UseCases\UpdateSet\UpdateIconImage;
use App\Set\UseCases\UpdateSet\UpdateIconImageRequest;
-use App\Set\UseCases\UpdateSet\UpdateName;
-use App\Set\UseCases\UpdateSet\UpdateNameRequest;
use Tests\Fakes\FakeFilesystem;
use Tests\Fakes\FakeSetRepository;
use Tests\TestCase;
-class UpdateSetFieldsTest extends TestCase
+class UpdateIconImageTest extends TestCase
{
private FakeSetRepository $setRepository;
@@ -27,79 +23,7 @@ class UpdateSetFieldsTest extends TestCase
$this->filesystem = new FakeFilesystem();
}
- public function testUpdateNameUpdatesOnlyName(): 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 testUpdateNameRejectsBlankName(): 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: '',
- ));
- }
-
- public function testUpdateDescriptionUpdatesOnlyDescription(): 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 testUpdateDescriptionRejectsBlankDescription(): 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: '',
- ));
- }
-
- public function testUpdateIconImageUpdatesOnlyIconAndDeletesOldIcon(): void
+ public function testUpdatesOnlyIconAndDeletesOldIcon(): void
{
$set = $this->createSet();
$updateIconImage = new UpdateIconImage(
@@ -131,7 +55,7 @@ class UpdateSetFieldsTest extends TestCase
], $this->filesystem->deletedPaths);
}
- public function testUpdateIconImageRejectsMissingFile(): void
+ public function testRejectsMissingFile(): void
{
$set = $this->createSet();
$updateIconImage = new UpdateIconImage(
@@ -151,7 +75,7 @@ class UpdateSetFieldsTest extends TestCase
));
}
- public function testUpdateIconImageRejectsInvalidMimeType(): void
+ public function testRejectsInvalidMimeType(): void
{
$set = $this->createSet();
$updateIconImage = new UpdateIconImage(
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',
+ ));
+ }
+}