From 7c84eefe785240ee752994312804bbb28a42a472 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 21:44:06 +0300 Subject: [PATCH 1/6] test child creation --- .../tests/Feature/ElementsEndpointTest.php | 87 +++++++++++++++ .../UseCases/CreateChildElementTest.php | 102 ++++++++++++++++++ .../cypress/e2e/admin-element.cy.ts | 24 +++++ 3 files changed, 213 insertions(+) create mode 100644 backend/tests/Unit/Element/UseCases/CreateChildElementTest.php diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index cd17cd2..01bd6a7 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -142,6 +142,93 @@ class ElementsEndpointTest extends TestCase ]); } + public function testCreateChildRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $this->createSet($setRepository); + $parentElement = $this->createElement( + $elementRepository, + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + null, + null, + null, + null, + ); + + $response = $this->postJson( + "/api/elements/{$parentElement->getId()}/children", + [ + 'title' => 'New child', + ], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedCreateChildReturnsElementPayload(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $this->createSet($setRepository); + $parentElement = $this->createElement( + $elementRepository, + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + null, + null, + null, + null, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->postJson( + "/api/elements/{$parentElement->getId()}/children", + [ + 'title' => 'New admin child', + ], + ); + + $response->assertCreated(); + $body = $response->json(); + $childElementId = $body['element']['id']; + $response->assertExactJson([ + 'element' => [ + 'id' => $childElementId, + 'title' => 'New admin child', + 'description' => '', + 'iconImageUrl' => null, + 'richText' => '', + 'shortPdfPath' => null, + 'longPdfPath' => null, + 'youtubeUrl' => null, + ], + ]); + + $createdElement = $elementRepository->find($childElementId); + $this->assertNotNull($createdElement); + $this->assertSame( + $parentElement->getId(), + $createdElement->getParentElement()->getId(), + ); + $this->assertSame( + $parentElement->getSet()->getId(), + $createdElement->getSet()->getId(), + ); + } + public function testAuthenticatedUpdateReturnsElementPayload(): void { $sampleYoutubeUrl = 'https://www.youtube.com/watch?v=' diff --git a/backend/tests/Unit/Element/UseCases/CreateChildElementTest.php b/backend/tests/Unit/Element/UseCases/CreateChildElementTest.php new file mode 100644 index 0000000..85819c5 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/CreateChildElementTest.php @@ -0,0 +1,102 @@ +elementRepository = new FakeElementRepository(); + $this->createChildElement = new CreateChildElement( + $this->elementRepository, + ); + } + + public function testCreatesChildElementFromParentSet(): void + { + $parentElement = $this->createParentElement(); + + $childElement = $this->createChildElement->execute( + new CreateChildElementRequest( + parentElementId: $parentElement->getId(), + title: 'Admin child', + ) + ); + + $this->assertSame('Admin child', $childElement->getTitle()); + $this->assertSame('', $childElement->getDescription()); + $this->assertNull($childElement->getIconImageUrl()); + $this->assertSame('', $childElement->getRichText()); + $this->assertNull($childElement->getShortPdfPath()); + $this->assertNull($childElement->getLongPdfPath()); + $this->assertNull($childElement->getYoutubeUrl()); + $this->assertSame( + $parentElement->getSet()->getId(), + $childElement->getSet()->getId(), + ); + $this->assertSame( + $parentElement->getId(), + $childElement->getParentElement()->getId(), + ); + } + + public function testThrowsWhenParentElementIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Parent element not found'); + + $this->createChildElement->execute(new CreateChildElementRequest( + parentElementId: 999, + title: 'Missing parent child', + )); + } + + public function testThrowsWhenTitleIsMissing(): void + { + $parentElement = $this->createParentElement(); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $this->createChildElement->execute(new CreateChildElementRequest( + parentElementId: $parentElement->getId(), + title: '', + )); + } + + private function createParentElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: 'Parent', + description: 'Parent description', + iconImageUrl: null, + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index f172d92..abf45e1 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -139,6 +139,30 @@ describe('admin element editing', () => { cy.get('[data-cy="element-rich-text"]').should('not.exist') }) + it('adds a child element and opens it for editing', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('POST', /\/api\/elements\/1\/children$/) + .as('createChildElement') + + cy.get('[data-cy="admin-child-title"]').type('Admin added child') + cy.get('[data-cy="admin-add-child"]').click() + cy.wait('@createChildElement') + + cy.location('pathname').should('match', /^\/admin\/element\/\d+$/) + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'Admin added child') + cy.contains('header.site-header a', 'View Element').click() + cy.contains('h1', 'Admin added child').should('be.visible') + + cy.visit('/element/1') + cy.get('[data-cy="child-element-list"]') + .should('contain.text', 'Admin added child') + + cy.resetDb() + }) + it('uploads icon and pdf files immediately through the UI', () => { cy.resetDb() loginAsAdmin() From 34b0fd0b442aefa8dd9a62be784f93d9917c9f66 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:02:36 +0300 Subject: [PATCH 2/6] add child creation --- backend/app/Controllers/ElementController.php | 27 +++ .../CreateChildElement/CreateChildElement.php | 50 +++++ .../CreateChildElementRequest.php | 12 ++ backend/routes/api.php | 5 + .../Controllers/ElementControllerTest.php | 2 + frontend/rabbi_gerzi/src/stores/elements.ts | 64 +++++++ .../src/views/AdminElementPage.vue | 174 +++++++++++++++++- 7 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 backend/app/Element/UseCases/CreateChildElement/CreateChildElement.php create mode 100644 backend/app/Element/UseCases/CreateChildElement/CreateChildElementRequest.php diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 9ad8ed8..69ef8ce 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -3,6 +3,8 @@ namespace App\Controllers; use App\Element\Element; +use App\Element\UseCases\CreateChildElement\CreateChildElement; +use App\Element\UseCases\CreateChildElement\CreateChildElementRequest; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; use App\Element\UseCases\UpdateElement\UpdateElement; @@ -18,6 +20,7 @@ class ElementController { public function __construct( private GetElement $getElement, + private CreateChildElement $createChildElement, private UpdateElement $updateElement, private FileUploader $fileUploader, ) { @@ -55,6 +58,30 @@ class ElementController ], 200); } + public function createChild(Request $request, ?int $parentId): JsonResponse + { + try { + $element = $this->createChildElement->execute( + new CreateChildElementRequest( + parentElementId: $parentId, + title: $this->stringInput($request, 'title'), + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 201); + } + public function update(Request $request): JsonResponse { $file = $this->uploadedFileInput($request, 'file'); diff --git a/backend/app/Element/UseCases/CreateChildElement/CreateChildElement.php b/backend/app/Element/UseCases/CreateChildElement/CreateChildElement.php new file mode 100644 index 0000000..0286abf --- /dev/null +++ b/backend/app/Element/UseCases/CreateChildElement/CreateChildElement.php @@ -0,0 +1,50 @@ +parentElementId === null) { + throw new BadRequestException('parentElementId is required'); + } + + if ($request->title === null || $request->title === '') { + throw new BadRequestException('title is required'); + } + + $parentElement = $this->elementRepository->find( + $request->parentElementId + ); + if ($parentElement === null) { + throw new NotFoundException('Parent element not found'); + } + + return $this->elementRepository->create(new CreateElementDto( + set: $parentElement->getSet(), + title: $request->title, + description: '', + iconImageUrl: null, + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: $parentElement, + )); + } +} diff --git a/backend/app/Element/UseCases/CreateChildElement/CreateChildElementRequest.php b/backend/app/Element/UseCases/CreateChildElement/CreateChildElementRequest.php new file mode 100644 index 0000000..c4f5931 --- /dev/null +++ b/backend/app/Element/UseCases/CreateChildElement/CreateChildElementRequest.php @@ -0,0 +1,12 @@ +middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); +Route::post('/elements/{parentId}/children', [ + ElementController::class, + 'createChild', +]) + ->middleware(AuthMiddleware::class); Route::post('/element/update', [ElementController::class, 'update']) ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 7d97527..9e3d9ff 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Controllers; use App\Controllers\ElementController; use App\Element\CreateElementDto; use App\Element\Element; +use App\Element\UseCases\CreateChildElement\CreateChildElement; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; @@ -61,6 +62,7 @@ class ElementControllerTest extends TestCase ); $this->controller = new ElementController( $getElement, + new CreateChildElement($this->elementRepo), $updateElement, $this->fileUploader, ); diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 20cca63..cf3488a 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -29,6 +29,10 @@ export interface UpdateElementInput { youtubeUrl: string } +export interface CreateChildElementInput { + title: string +} + interface UpdateElementResponse { element: Element } @@ -48,6 +52,7 @@ interface ErrorResponse { } const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string +const ELEMENTS_URL = `${API_BASE_URL}/api/elements` const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update` export const useElementsStore = defineStore('elements', () => { @@ -59,7 +64,9 @@ export const useElementsStore = defineStore('elements', () => { const isUploadingIconImage = ref(false) const isUploadingShortPdf = ref(false) const isUploadingLongPdf = ref(false) + const isManagingChildren = ref(false) const saveError = ref(null) + const childActionError = ref(null) async function fetchElement(elementId: string): Promise { element.value = null @@ -101,6 +108,37 @@ export const useElementsStore = defineStore('elements', () => { return await saveElementPatch(elementId, input, 'Could not save element') } + async function createChildElement( + parentElementId: string, + input: CreateChildElementInput, + ): Promise { + childActionError.value = null + isManagingChildren.value = true + + try { + const encodedParentElementId = encodeURIComponent(parentElementId) + const response = await fetch( + `${ELEMENTS_URL}/${encodedParentElementId}/children`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(input), + }, + ) + + return await handleChildElementResponse( + response, + 'Could not add child element', + ) + } catch { + childActionError.value = 'Network error - could not add child element' + return null + } finally { + isManagingChildren.value = false + } + } + async function clearElementIconImage(elementId: string): Promise { return await saveElementPatch( elementId, @@ -253,6 +291,29 @@ export const useElementsStore = defineStore('elements', () => { return true } + async function handleChildElementResponse( + response: Response, + failureMessage: string, + ): Promise { + if (response.status === 401) { + childActionError.value = 'Please log in again' + return null + } + + if (response.status === 400 || response.status === 404) { + childActionError.value = await errorMessage(response, failureMessage) + return null + } + + if (!response.ok) { + childActionError.value = failureMessage + return null + } + + const data: UpdateElementResponse = await response.json() + return data.element + } + async function errorMessage( response: Response, fallbackMessage: string, @@ -274,9 +335,12 @@ export const useElementsStore = defineStore('elements', () => { isUploadingIconImage, isUploadingShortPdf, isUploadingLongPdf, + isManagingChildren, saveError, + childActionError, fetchElement, updateElement, + createChildElement, uploadElementIconImage, uploadElementShortPdf, uploadElementLongPdf, diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index ab6e470..9a0632d 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -1,7 +1,7 @@