diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index a914dd9..329e932 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -297,6 +297,234 @@ class ElementsEndpointTest extends TestCase ); } + public function testReorderChildElementsRequiresAuthentication(): 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, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + + $response = $this->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [$firstChildElement->getId()], + ], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedReorderChildElementsPersistsOrder(): 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, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $elementRepository, + $set, + 'Second child', + 'Second child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $thirdChildElement = $this->createElement( + $elementRepository, + $set, + 'Third child', + 'Third child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ], + ); + + $response->assertOk(); + $response->assertExactJson([ + 'childElements' => [ + [ + 'id' => $thirdChildElement->getId(), + 'title' => 'Third child', + 'description' => 'Third child description', + ], + [ + 'id' => $firstChildElement->getId(), + 'title' => 'First child', + 'description' => 'First child description', + ], + [ + 'id' => $secondChildElement->getId(), + 'title' => 'Second child', + 'description' => 'Second child description', + ], + ], + ]); + + $this->getJson("/api/elements/{$parentElement->getId()}") + ->assertJsonPath('childElements.0.id', $thirdChildElement->getId()) + ->assertJsonPath('childElements.1.id', $firstChildElement->getId()) + ->assertJsonPath( + 'childElements.2.id', + $secondChildElement->getId(), + ); + $this->getJson("/api/elements/{$thirdChildElement->getId()}") + ->assertJsonPath( + 'siblingElements.0.id', + $thirdChildElement->getId(), + ) + ->assertJsonPath( + 'siblingElements.1.id', + $firstChildElement->getId(), + ) + ->assertJsonPath( + 'siblingElements.2.id', + $secondChildElement->getId(), + ); + } + + public function testAuthenticatedReorderRejectsNestedChildElement(): 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, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $elementRepository, + $set, + 'Second child', + 'Second child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $nestedChildElement = $this->createElement( + $elementRepository, + $set, + 'Nested child', + 'Nested child description', + null, + '', + null, + null, + null, + $firstChildElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [ + $firstChildElement->getId(), + $nestedChildElement->getId(), + $secondChildElement->getId(), + ], + ], + ); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'error' => 'Child order contains invalid child', + ]); + } + public function testDeleteChildRequiresAuthentication(): void { $setRepository = app(SetRepository::class); diff --git a/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php b/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php new file mode 100644 index 0000000..1b4829f --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php @@ -0,0 +1,237 @@ +elementRepository = new FakeElementRepository(); + $this->reorderChildElements = new ReorderChildElements( + $this->elementRepository, + ); + } + + public function testReordersDirectChildElements(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + $thirdChildElement = $this->createElement( + $set, + 'Third child', + $parentElement, + ); + $this->createElement($set, 'Nested child', $firstChildElement); + + $childElements = $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + + $this->assertSame( + [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + $this->elementIds($childElements), + ); + $this->assertSame( + [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + $this->elementIds( + $this->elementRepository->findByParentElement($parentElement), + ), + ); + } + + public function testThrowsWhenParentElementIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Parent element not found'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: 999, + childElementIds: [1, 2], + ) + ); + } + + public function testThrowsWhenChildIdsAreMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('childElementIds is required'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: 1, + childElementIds: null, + ) + ); + } + + public function testThrowsWhenChildOrderHasDuplicates(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Child order contains duplicate ids'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $firstChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + } + + public function testThrowsWhenChildOrderHasInvalidElement(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + $nestedChildElement = $this->createElement( + $set, + 'Nested child', + $firstChildElement, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Child order contains invalid child'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $firstChildElement->getId(), + $nestedChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + } + + public function testThrowsWhenChildOrderOmitsDirectChild(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $this->createElement($set, 'Second child', $parentElement); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'Child order must include every direct child', + ); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [$firstChildElement->getId()], + ) + ); + } + + private function createSet(int $id, string $name): DomainSet + { + return new DomainSet( + id: $id, + name: $name, + description: "$name description", + iconImageUrl: '/assets/baderech-icon.png', + ); + } + + private function createElement( + DomainSet $set, + string $title, + ?Element $parentElement, + ): Element { + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: $title, + description: "$title description", + iconImageUrl: null, + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: $parentElement, + )); + } + + /** + * @param Element[] $elements + * @return int[] + */ + private function elementIds(array $elements): array + { + $elementIds = []; + foreach ($elements as $element) { + $elementIds[] = $element->getId(); + } + + return $elementIds; + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 8c1f7f3..ab70619 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -191,6 +191,46 @@ describe('admin element editing', () => { cy.resetDb() }) + it('reorders child elements through admin controls', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('PUT', /\/api\/elements\/1\/children\/order$/) + .as('reorderChildElements') + + cy.contains('[data-cy="admin-child-item"]', '2. Foundations') + .within(() => { + cy.get('[data-cy="admin-move-child-up"]').click() + }) + cy.wait('@reorderChildElements') + + cy.get('[data-cy="admin-child-status"]') + .should('be.visible') + .and('contain.text', 'Child order saved') + cy.get('[data-cy="admin-child-item"]') + .eq(0) + .should('contain.text', '2. Foundations') + cy.get('[data-cy="admin-child-item"]') + .eq(1) + .should('contain.text', '1. Introduction') + + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="child-element-link"]') + .eq(0) + .should('contain.text', '2. Foundations') + cy.get('[data-cy="child-element-link"]') + .eq(1) + .should('contain.text', '1. Introduction') + + cy.visit('/element/3') + cy.get('[data-cy="element-sibling-previous"]') + .should('have.attr', 'href', '/element/7') + cy.get('[data-cy="element-sibling-next"]') + .should('have.attr', 'href', '/element/2') + + cy.resetDb() + }) + it('uploads icon and pdf files immediately through the UI', () => { cy.resetDb() loginAsAdmin()