From 11a0b1576fa934b8c5da2d91d062be61aa8abac5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:05:14 +0300 Subject: [PATCH] test child deletion --- .../tests/Feature/ElementsEndpointTest.php | 174 ++++++++++++++++++ .../UseCases/DeleteChildElementTest.php | 173 +++++++++++++++++ .../cypress/e2e/admin-element.cy.ts | 28 +++ 3 files changed, 375 insertions(+) create mode 100644 backend/tests/Unit/Element/UseCases/DeleteChildElementTest.php diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 01bd6a7..157f944 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -229,6 +229,180 @@ class ElementsEndpointTest extends TestCase ); } + public function testDeleteChildRequiresAuthentication(): 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, + ); + $childElement = $this->createElement( + $elementRepository, + $set, + 'Child', + 'Child description', + null, + '', + null, + null, + null, + $parentElement, + ); + + $response = $this->deleteJson( + "/api/elements/{$parentElement->getId()}" + . "/children/{$childElement->getId()}", + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedDeleteChildRemovesSubtree(): void + { + Storage::fake('public'); + Storage::disk('public')->put('element-icons/child.png', 'child icon'); + Storage::disk('public')->put( + 'element-pdfs/short/child.pdf', + 'child short pdf', + ); + Storage::disk('public')->put( + 'element-pdfs/long/grandchild.pdf', + 'grandchild long pdf', + ); + $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, + ); + $childElement = $this->createElement( + $elementRepository, + $set, + 'Child', + 'Child description', + 'element-icons/child.png', + '', + 'element-pdfs/short/child.pdf', + null, + null, + $parentElement, + ); + $grandchildElement = $this->createElement( + $elementRepository, + $set, + 'Grandchild', + 'Grandchild description', + null, + '', + null, + 'element-pdfs/long/grandchild.pdf', + null, + $childElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->deleteJson( + "/api/elements/{$parentElement->getId()}" + . "/children/{$childElement->getId()}", + ); + + $response->assertNoContent(); + $this->assertNull($elementRepository->find($childElement->getId())); + $this->assertNull( + $elementRepository->find($grandchildElement->getId()), + ); + Storage::disk('public')->assertMissing('element-icons/child.png'); + Storage::disk('public')->assertMissing( + 'element-pdfs/short/child.pdf', + ); + Storage::disk('public')->assertMissing( + 'element-pdfs/long/grandchild.pdf', + ); + } + + public function testAuthenticatedDeleteChildRejectsNonDirectChild(): 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, + ); + $childElement = $this->createElement( + $elementRepository, + $set, + 'Child', + 'Child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $grandchildElement = $this->createElement( + $elementRepository, + $set, + 'Grandchild', + 'Grandchild description', + null, + '', + null, + null, + null, + $childElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->deleteJson( + "/api/elements/{$parentElement->getId()}" + . "/children/{$grandchildElement->getId()}", + ); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'error' => 'Child element does not belong to parent', + ]); + $this->assertNotNull( + $elementRepository->find($grandchildElement->getId()), + ); + } + public function testAuthenticatedUpdateReturnsElementPayload(): void { $sampleYoutubeUrl = 'https://www.youtube.com/watch?v=' diff --git a/backend/tests/Unit/Element/UseCases/DeleteChildElementTest.php b/backend/tests/Unit/Element/UseCases/DeleteChildElementTest.php new file mode 100644 index 0000000..bac9af2 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/DeleteChildElementTest.php @@ -0,0 +1,173 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->deleteChildElement = new DeleteChildElement( + $this->elementRepository, + $this->fileUploader, + ); + } + + public function testDeletesChildSubtreeAndManagedFiles(): void + { + $parentElement = $this->createElement( + parentElement: null, + title: 'Parent', + iconImageUrl: null, + shortPdfPath: null, + longPdfPath: null, + ); + $childElement = $this->createElement( + parentElement: $parentElement, + title: 'Child', + iconImageUrl: 'element-icons/child.png', + shortPdfPath: 'element-pdfs/short/child.pdf', + longPdfPath: 'element-pdfs/long/child.pdf', + ); + $grandchildElement = $this->createElement( + parentElement: $childElement, + title: 'Grandchild', + iconImageUrl: 'element-icons/grandchild.png', + shortPdfPath: 'element-pdfs/short/grandchild.pdf', + longPdfPath: 'element-pdfs/long/grandchild.pdf', + ); + + $this->deleteChildElement->execute(new DeleteChildElementRequest( + parentElementId: $parentElement->getId(), + childElementId: $childElement->getId(), + )); + + $this->assertNull( + $this->elementRepository->find($childElement->getId()), + ); + $this->assertNull( + $this->elementRepository->find($grandchildElement->getId()), + ); + $this->assertSame([ + 'element-icons/grandchild.png', + 'element-pdfs/short/grandchild.pdf', + 'element-pdfs/long/grandchild.pdf', + 'element-icons/child.png', + 'element-pdfs/short/child.pdf', + 'element-pdfs/long/child.pdf', + ], $this->fileUploader->deletedPaths); + } + + public function testThrowsWhenParentElementIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Parent element not found'); + + $this->deleteChildElement->execute(new DeleteChildElementRequest( + parentElementId: 999, + childElementId: 1, + )); + } + + public function testThrowsWhenChildElementIsMissing(): void + { + $parentElement = $this->createElement( + parentElement: null, + title: 'Parent', + iconImageUrl: null, + shortPdfPath: null, + longPdfPath: null, + ); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Child element not found'); + + $this->deleteChildElement->execute(new DeleteChildElementRequest( + parentElementId: $parentElement->getId(), + childElementId: 999, + )); + } + + public function testThrowsWhenChildIsNotDirectChild(): void + { + $parentElement = $this->createElement( + parentElement: null, + title: 'Parent', + iconImageUrl: null, + shortPdfPath: null, + longPdfPath: null, + ); + $childElement = $this->createElement( + parentElement: $parentElement, + title: 'Child', + iconImageUrl: null, + shortPdfPath: null, + longPdfPath: null, + ); + $grandchildElement = $this->createElement( + parentElement: $childElement, + title: 'Grandchild', + iconImageUrl: null, + shortPdfPath: null, + longPdfPath: null, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'Child element does not belong to parent' + ); + + $this->deleteChildElement->execute(new DeleteChildElementRequest( + parentElementId: $parentElement->getId(), + childElementId: $grandchildElement->getId(), + )); + } + + private function createElement( + ?Element $parentElement, + string $title, + ?string $iconImageUrl, + ?string $shortPdfPath, + ?string $longPdfPath, + ): Element { + return $this->elementRepository->create(new CreateElementDto( + set: $this->createSet(), + title: $title, + description: "$title description", + iconImageUrl: $iconImageUrl, + richText: '', + shortPdfPath: $shortPdfPath, + longPdfPath: $longPdfPath, + youtubeUrl: null, + parentElement: $parentElement, + )); + } + + private function createSet(): DomainSet + { + return new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index abf45e1..8c1f7f3 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -163,6 +163,34 @@ describe('admin element editing', () => { cy.resetDb() }) + it('removes a child element after confirmation', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('DELETE', /\/api\/elements\/1\/children\/\d+$/) + .as('deleteChildElement') + cy.window().then((windowObject) => { + cy.stub(windowObject, 'confirm').returns(true) + }) + + cy.contains('[data-cy="admin-child-item"]', '1. Introduction') + .within(() => { + cy.contains('button', 'Remove').click() + }) + cy.wait('@deleteChildElement') + + cy.get('[data-cy="admin-child-status"]') + .should('be.visible') + .and('contain.text', 'Child element removed') + cy.contains('[data-cy="admin-child-item"]', '1. Introduction') + .should('not.exist') + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="child-element-list"]') + .should('not.contain.text', '1. Introduction') + + cy.resetDb() + }) + it('uploads icon and pdf files immediately through the UI', () => { cy.resetDb() loginAsAdmin()