From 2121a0ba9d0b681082aa0aaccf0fba1fdc7a0ce3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:27:02 +0300 Subject: [PATCH 1/9] add tests for admin text page, move some tests over from original admin test file --- cypress/e2e/admin.cy.js | 17 -------- cypress/e2e/adminText.cy.js | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 cypress/e2e/adminText.cy.js diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index ba8c069..2c8054c 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -26,23 +26,6 @@ describe('The admin page', () => { cy.contains('Test Text') }) - it('shows one root node and child node on the seeded text page', () => { - cy.visit('/admin/texts/0') - cy.intercept('GET', '/api/nodes/0').as('getNodes') - cy.wait('@getNodes') - cy.get('#text-detail > ul > li > ul > li').should('have.length', 1) - }) - - it('shows one root node on the text page', () => { - cy.visit('/admin/texts') - cy.get('#newTextName').type('My Node Text') - cy.get('#submit').click() - cy.intercept('GET', '/api/nodes/1').as('getNodes') - cy.get('a').contains('My Node Text').click() - cy.wait('@getNodes') - cy.get('#text-detail > ul').should('have.length', 1) - }) - it('navigates to a specific texts page', () => { cy.visit('/admin/texts') cy.get('#newTextName').type('My New Text') diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js new file mode 100644 index 0000000..9dae1bf --- /dev/null +++ b/cypress/e2e/adminText.cy.js @@ -0,0 +1,86 @@ +describe('The admin text detail page', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + cy.intercept('GET', '/api/texts/0').as('getText') + cy.intercept('GET', '/api/nodes/0').as('getNodes') + cy.visit('/admin/texts/0') + cy.wait('@getText') + cy.wait('@getNodes') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('shows the text name as a heading', () => { + cy.get('h1').should('contain', 'test text') + }) + + it('shows the root node', () => { + cy.get('#text-detail li').first().should('contain', 'Chapter 1') + }) + + it('shows a child node under the root node', () => { + cy.get('#text-detail > ul > li > ul > li').should('contain', 'Section 1.1') + }) + + it('shows an "Add child" button on each node', () => { + cy.get('#text-detail li').each(($li) => { + cy.wrap($li).find('button.add-child').should('exist') + }) + }) + + it('clicking "Add child" reveals an inline form', () => { + cy.get('#text-detail li').first().find('button.add-child').click() + cy.get('#text-detail li').first().find('input.child-title').should('be.visible') + cy.get('#text-detail li').first().find('button.save-child').should('be.visible') + }) + + it('can add a child to the root node', () => { + cy.intercept('POST', '/api/nodes').as('createNode') + cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + + cy.get('#text-detail > ul > li').first().find('button.add-child').click() + cy.get('#text-detail > ul > li').first().find('input.child-title').type('New Child Node') + cy.get('#text-detail > ul > li').first().find('button.save-child').click() + + cy.wait('@createNode').its('response.statusCode').should('eq', 201) + cy.wait('@getNodesRefresh') + + cy.get('#text-detail li').should('contain', 'New Child Node') + }) + + it('can add a child to a child node', () => { + cy.intercept('POST', '/api/nodes').as('createNode') + cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + + cy.get('#text-detail > ul > li > ul > li').first().find('button.add-child').click() + cy.get('#text-detail > ul > li > ul > li').first().find('input.child-title').type('Nested Child Node') + cy.get('#text-detail > ul > li > ul > li').first().find('button.save-child').click() + + cy.wait('@createNode').its('response.statusCode').should('eq', 201) + cy.wait('@getNodesRefresh') + + cy.get('#text-detail li').should('contain', 'Nested Child Node') + }) + + it('newly added child persists after page reload', () => { + cy.intercept('POST', '/api/nodes').as('createNode') + cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + + cy.get('#text-detail > ul > li').first().find('button.add-child').click() + cy.get('#text-detail > ul > li').first().find('input.child-title').type('Persistent Child') + cy.get('#text-detail > ul > li').first().find('button.save-child').click() + + cy.wait('@createNode') + cy.wait('@getNodesRefresh') + + cy.intercept('GET', '/api/texts/0').as('getTextReload') + cy.intercept('GET', '/api/nodes/0').as('getNodesReload') + cy.reload() + cy.wait('@getTextReload') + cy.wait('@getNodesReload') + + cy.get('#text-detail li').should('contain', 'Persistent Child') + }) +}) From 1c1f8b5a588302a47508cd9702a6c198e7f05bbe Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:27:36 +0300 Subject: [PATCH 2/9] add tests for node controller get nodes of text and create node --- tests/e2e/Controllers/NodeControllerTest.php | 152 +++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/e2e/Controllers/NodeControllerTest.php diff --git a/tests/e2e/Controllers/NodeControllerTest.php b/tests/e2e/Controllers/NodeControllerTest.php new file mode 100644 index 0000000..28f0932 --- /dev/null +++ b/tests/e2e/Controllers/NodeControllerTest.php @@ -0,0 +1,152 @@ +textRepo = new FakeTextRepository; + $this->textRepo->create(new CreateTextDto(name: 'test text')); + + $this->nodeRepo = new FakeNodeRepository; + + $this->controller = new NodeController($this->nodeRepo, $this->textRepo); + } + + public function test_get_nodes_of_text_returns_flat_array(): void + { + $text = $this->textRepo->find(0); + $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root Node', + parentNode: null, + )); + + $response = $this->controller->getNodesOfText(new Response(), 0); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals( + json_encode([ + ['id' => 0, 'title' => 'Root Node', 'parentNodeId' => null], + ]), + $response->getBody(), + ); + } + + public function test_get_nodes_of_text_returns_empty_array_when_no_nodes(): void + { + $response = $this->controller->getNodesOfText(new Response(), 0); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(json_encode([]), $response->getBody()); + } + + public function test_get_nodes_of_text_returns_404_for_unknown_text(): void + { + $response = $this->controller->getNodesOfText(new Response(), 99); + + $this->assertEquals(404, $response->getStatusCode()); + } + + public function test_get_nodes_includes_parent_node_id(): void + { + $text = $this->textRepo->find(0); + $rootNode = $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root Node', + parentNode: null, + )); + $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Child Node', + parentNode: $rootNode, + )); + + $response = $this->controller->getNodesOfText(new Response(), 0); + $body = json_decode($response->getBody(), true); + + $this->assertEquals(0, $body[1]['parentNodeId']); + } + + public function test_create_node_returns_created_node(): void + { + $text = $this->textRepo->find(0); + $rootNode = $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root Node', + parentNode: null, + )); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', 'http://localhost/api/nodes') + ->withParsedBody([ + 'textId' => 0, + 'title' => 'Child Node', + 'parentNodeId' => $rootNode->getId(), + ]); + + $response = $this->controller->createNode( + $request, + new Response(), + new CreateNode($this->nodeRepo, $this->textRepo), + ); + + $this->assertEquals(201, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertEquals('Child Node', $body['title']); + $this->assertEquals($rootNode->getId(), $body['parentNodeId']); + $this->assertArrayHasKey('id', $body); + } + + public function test_create_node_returns_400_when_title_missing(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', 'http://localhost/api/nodes') + ->withParsedBody([ + 'textId' => 0, + 'parentNodeId' => null, + ]); + + $response = $this->controller->createNode( + $request, + new Response(), + new CreateNode($this->nodeRepo, $this->textRepo), + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_create_node_returns_404_when_text_not_found(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', 'http://localhost/api/nodes') + ->withParsedBody([ + 'textId' => 99, + 'title' => 'Some Node', + 'parentNodeId' => null, + ]); + + $response = $this->controller->createNode( + $request, + new Response(), + new CreateNode($this->nodeRepo, $this->textRepo), + ); + + $this->assertEquals(404, $response->getStatusCode()); + } +} From 571c0d1196cc4e761ce4e0eadda86e3664f1f5a1 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:32:12 +0300 Subject: [PATCH 3/9] add endpoint for creating a node --- bootstrap/app.php | 1 + 1 file changed, 1 insertion(+) diff --git a/bootstrap/app.php b/bootstrap/app.php index 37d86df..cd895b3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,5 +22,6 @@ $app->get('/api/texts/{textId}', [TextController::class, 'getText']); $app->post('/api/texts', [TextController::class, 'createText']); $app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); +$app->post('/api/nodes', [NodeController::class, 'createNode']); return $app; From bdf386e5106897d23bcb18a78a287bdb2b643647 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:33:41 +0300 Subject: [PATCH 4/9] implement create node method in node controller --- app/Node/NodeController.php | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php index 254807b..57a1cf0 100644 --- a/app/Node/NodeController.php +++ b/app/Node/NodeController.php @@ -3,8 +3,12 @@ namespace App\Node; use App\Node\NodeRepository; +use App\Node\UseCases\CreateNode; +use App\Node\UseCases\CreateNodeRequest; use App\Text\TextRepository; +use DomainException; use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; class NodeController { @@ -34,4 +38,41 @@ class NodeController $response->getBody()->write(json_encode(array_values($data))); return $response->withHeader('Content-Type', 'application/json'); } + + public function createNode( + Request $request, + Response $response, + CreateNode $createNodeUseCase, + ): Response { + $data = $request->getParsedBody(); + $title = $data['title'] ?? ''; + + if (empty($title)) { + $response->getBody()->write(json_encode(['error' => 'Title is required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $textId = (int) ($data['textId'] ?? 0); + $parentNodeId = isset($data['parentNodeId']) && $data['parentNodeId'] !== null + ? (int) $data['parentNodeId'] + : null; + + try { + $node = $createNodeUseCase->execute(new CreateNodeRequest( + textId: $textId, + title: $title, + parentNodeId: $parentNodeId, + )); + } catch (DomainException $e) { + $response->getBody()->write(json_encode(['error' => $e->getMessage()])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write(json_encode([ + 'id' => $node->getId(), + 'title' => $node->getTitle(), + 'parentNodeId' => $node->getParentNode()?->getId(), + ])); + return $response->withStatus(201)->withHeader('Content-Type', 'application/json'); + } } From b12d0fc7d3180e074a721a49629a962225fc1f12 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:48:41 +0300 Subject: [PATCH 5/9] fix cypress tests to properly select in children --- cypress/e2e/adminText.cy.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js index 9dae1bf..18daef3 100644 --- a/cypress/e2e/adminText.cy.js +++ b/cypress/e2e/adminText.cy.js @@ -31,18 +31,18 @@ describe('The admin text detail page', () => { }) it('clicking "Add child" reveals an inline form', () => { - cy.get('#text-detail li').first().find('button.add-child').click() - cy.get('#text-detail li').first().find('input.child-title').should('be.visible') - cy.get('#text-detail li').first().find('button.save-child').should('be.visible') + cy.get('#text-detail li').first().children('button.add-child').click() + cy.get('#text-detail li').first().children('input.child-title').should('be.visible') + cy.get('#text-detail li').first().children('button.save-child').should('be.visible') }) it('can add a child to the root node', () => { cy.intercept('POST', '/api/nodes').as('createNode') cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') - cy.get('#text-detail > ul > li').first().find('button.add-child').click() - cy.get('#text-detail > ul > li').first().find('input.child-title').type('New Child Node') - cy.get('#text-detail > ul > li').first().find('button.save-child').click() + cy.get('#text-detail > ul > li').first().children('button.add-child').click() + cy.get('#text-detail > ul > li').first().children('input.child-title').type('New Child Node') + cy.get('#text-detail > ul > li').first().children('button.save-child').click() cy.wait('@createNode').its('response.statusCode').should('eq', 201) cy.wait('@getNodesRefresh') @@ -54,9 +54,9 @@ describe('The admin text detail page', () => { cy.intercept('POST', '/api/nodes').as('createNode') cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') - cy.get('#text-detail > ul > li > ul > li').first().find('button.add-child').click() - cy.get('#text-detail > ul > li > ul > li').first().find('input.child-title').type('Nested Child Node') - cy.get('#text-detail > ul > li > ul > li').first().find('button.save-child').click() + cy.get('#text-detail > ul > li > ul > li').first().children('button.add-child').click() + cy.get('#text-detail > ul > li > ul > li').first().children('input.child-title').type('Nested Child Node') + cy.get('#text-detail > ul > li > ul > li').first().children('button.save-child').click() cy.wait('@createNode').its('response.statusCode').should('eq', 201) cy.wait('@getNodesRefresh') @@ -68,9 +68,9 @@ describe('The admin text detail page', () => { cy.intercept('POST', '/api/nodes').as('createNode') cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') - cy.get('#text-detail > ul > li').first().find('button.add-child').click() - cy.get('#text-detail > ul > li').first().find('input.child-title').type('Persistent Child') - cy.get('#text-detail > ul > li').first().find('button.save-child').click() + cy.get('#text-detail > ul > li').first().children('button.add-child').click() + cy.get('#text-detail > ul > li').first().children('input.child-title').type('Persistent Child') + cy.get('#text-detail > ul > li').first().children('button.save-child').click() cy.wait('@createNode') cy.wait('@getNodesRefresh') From 56bdee86cce5edf16bdb79037e052c2f599c7596 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 21:59:57 +0300 Subject: [PATCH 6/9] fix controller tests to pass in data in proper form --- tests/e2e/Controllers/NodeControllerTest.php | 35 ++++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/e2e/Controllers/NodeControllerTest.php b/tests/e2e/Controllers/NodeControllerTest.php index 28f0932..ab01495 100644 --- a/tests/e2e/Controllers/NodeControllerTest.php +++ b/tests/e2e/Controllers/NodeControllerTest.php @@ -8,6 +8,7 @@ use App\Node\UseCases\CreateNode; use App\Text\CreateTextDto; use PHPUnit\Framework\TestCase; use Slim\Psr7\Factory\ServerRequestFactory; +use Slim\Psr7\Factory\StreamFactory; use Slim\Psr7\Response; use Tests\Fakes\FakeNodeRepository; use Tests\Fakes\FakeTextRepository; @@ -92,13 +93,15 @@ class NodeControllerTest extends TestCase parentNode: null, )); + $body = (new StreamFactory())->createStream(json_encode([ + 'textId' => 0, + 'title' => 'Child Node', + 'parentNodeId' => $rootNode->getId(), + ])); $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withParsedBody([ - 'textId' => 0, - 'title' => 'Child Node', - 'parentNodeId' => $rootNode->getId(), - ]); + ->withHeader('Content-Type', 'application/json') + ->withBody($body); $response = $this->controller->createNode( $request, @@ -115,12 +118,14 @@ class NodeControllerTest extends TestCase public function test_create_node_returns_400_when_title_missing(): void { + $body = (new StreamFactory())->createStream(json_encode([ + 'textId' => 0, + 'parentNodeId' => null, + ])); $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withParsedBody([ - 'textId' => 0, - 'parentNodeId' => null, - ]); + ->withHeader('Content-Type', 'application/json') + ->withBody($body); $response = $this->controller->createNode( $request, @@ -133,13 +138,15 @@ class NodeControllerTest extends TestCase public function test_create_node_returns_404_when_text_not_found(): void { + $body = (new StreamFactory())->createStream(json_encode([ + 'textId' => 99, + 'title' => 'Some Node', + 'parentNodeId' => null, + ])); $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withParsedBody([ - 'textId' => 99, - 'title' => 'Some Node', - 'parentNodeId' => null, - ]); + ->withHeader('Content-Type', 'application/json') + ->withBody($body); $response = $this->controller->createNode( $request, From 628c63382348417b44e4b51188b930bf9ffee0a5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 22:00:24 +0300 Subject: [PATCH 7/9] change the way data is accessed from request in node controller --- app/Node/NodeController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php index 57a1cf0..6104327 100644 --- a/app/Node/NodeController.php +++ b/app/Node/NodeController.php @@ -44,7 +44,7 @@ class NodeController Response $response, CreateNode $createNodeUseCase, ): Response { - $data = $request->getParsedBody(); + $data = json_decode((string) $request->getBody(), true) ?? []; $title = $data['title'] ?? ''; if (empty($title)) { From f277ae79836b41f2c943b038ee08b516adb48199 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 22:06:25 +0300 Subject: [PATCH 8/9] move fetch nodes by text id into its own function re renders if theres a root list of nodes existing --- public/js/text.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/public/js/text.js b/public/js/text.js index eafc1c6..8f7b72e 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -8,16 +8,23 @@ document.addEventListener('DOMContentLoaded', () => { h1.textContent = text.name; document.getElementById('text-detail').appendChild(h1); - return fetch('/api/nodes/' + textId ); - }) - .then(res => res.json()) - .then(nodes => { - const tree = buildTree(nodes); - const ul = renderTree(tree); - document.getElementById('text-detail').appendChild(ul); + return fetchAndRenderNodes(textId); }); }); +function fetchAndRenderNodes(textId) { + return fetch('/api/nodes/' + textId) + .then(res => res.json()) + .then(nodes => { + const existing = document.querySelector('#text-detail > ul'); + if (existing) existing.remove(); + + const tree = buildTree(nodes); + const ul = renderTree(tree, textId); + document.getElementById('text-detail').appendChild(ul); + }); +} + function buildTree(nodes) { const map = {}; nodes.forEach(node => { From 3ce6a91e6e96e53ad0c6669d9587c5c1f9c48fa6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 22:07:38 +0300 Subject: [PATCH 9/9] add and save button functionality --- public/js/text.js | 53 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/public/js/text.js b/public/js/text.js index 8f7b72e..fc62744 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -43,15 +43,62 @@ function buildTree(nodes) { return roots; } -function renderTree(nodes) { +function renderTree(nodes, textId) { const ul = document.createElement('ul'); nodes.forEach(node => { const li = document.createElement('li'); - li.textContent = node.title; + + const titleSpan = document.createElement('span'); + titleSpan.textContent = node.title; + li.appendChild(titleSpan); + + const addBtn = document.createElement('button'); + addBtn.textContent = 'Add child'; + addBtn.className = 'add-child'; + addBtn.addEventListener('click', () => toggleAddForm(li, node.id, textId)); + li.appendChild(addBtn); + if (node.children.length > 0) { - li.appendChild(renderTree(node.children)); + li.appendChild(renderTree(node.children, textId)); } + ul.appendChild(li); }); return ul; } + +function toggleAddForm(li, parentNodeId, textId) { + const existing = li.querySelector('input.child-title'); + if (existing) { + existing.remove(); + li.querySelector('button.save-child').remove(); + return; + } + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'child-title'; + input.placeholder = 'Node title'; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + saveBtn.className = 'save-child'; + saveBtn.addEventListener('click', () => { + const title = input.value.trim(); + if (!title) return; + + fetch('/api/nodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }), + }) + .then(res => { + if (!res.ok) throw new Error('Failed to create node'); + return res.json(); + }) + .then(() => fetchAndRenderNodes(textId)); + }); + + li.appendChild(input); + li.appendChild(saveBtn); +}