From eb7ac7d2611fa4c8c19591a3477d55477881477f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 23:30:11 +0300 Subject: [PATCH] test set layout browsing --- .../Feature/Database/DatabaseSeederTest.php | 58 ++++++ .../Element/EloquentElementRepositoryTest.php | 39 ++++ .../Feature/Set/EloquentSetRepositoryTest.php | 17 ++ .../Feature/Set/GetSetLayoutEndpointTest.php | 171 ++++++++++++++++ .../Unit/Set/UseCases/GetSetLayoutTest.php | 136 +++++++++++++ frontend/website/cypress/e2e/guest-auth.cy.ts | 7 + frontend/website/cypress/e2e/set-layout.cy.ts | 183 ++++++++++++++++++ .../website/cypress/e2e/sets-dashboard.cy.ts | 11 +- 8 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 backend/tests/Feature/Set/GetSetLayoutEndpointTest.php create mode 100644 backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php create mode 100644 frontend/website/cypress/e2e/set-layout.cy.ts diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index 45a7e51..e6ce46e 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\Database; use App\Auth\PasswordHasher; +use App\Element\Element; +use App\Element\ElementRepository; use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\UserRepository; @@ -60,4 +62,60 @@ class DatabaseSeederTest extends TestCase ); } } + + public function test_it_seeds_elements_for_every_set_idempotently(): void + { + $this->seed(); + $this->seed(); + + $sets = app(SetRepository::class)->all(); + $elementRepository = app(ElementRepository::class); + $expectedElements = [ + 'Bible' => [ + 'Chapter 1:chapter:Creation', + 'Chapter 2:chapter:Creation', + 'Creation:portion:Genesis', + 'Exodus:book:root', + 'Genesis:book:root', + 'Noah:portion:Genesis', + 'Shemot:portion:Exodus', + ], + 'Course' => [ + 'Applied Practice:module:root', + 'Core Concepts:lesson:Foundations', + 'Final Review:lesson:Applied Practice', + 'Foundations:module:root', + 'Guided Exercise:lesson:Applied Practice', + 'Welcome:lesson:Foundations', + ], + 'Fitness Program' => [ + 'Build Phase:phase:root', + 'Foundation Phase:phase:root', + 'Full Body Circuit:workout:Build Phase', + 'Hip Flow:exercise:Mobility Day', + 'Mobility Day:workout:Foundation Phase', + 'Push-up:exercise:Strength Day', + 'Squat:exercise:Strength Day', + 'Strength Day:workout:Foundation Phase', + ], + ]; + + $this->assertDatabaseCount('elements', 21); + + foreach ($sets as $set) { + $signatures = array_map(function (Element $element): string { + $parentName = $element->getParentElement()?->getName() + ?? 'root'; + + return "{$element->getName()}:{$element->getKind()}" + . ":{$parentName}"; + }, $elementRepository->findBySet($set)); + sort($signatures); + + $this->assertSame( + $expectedElements[$set->getName()], + $signatures, + ); + } + } } diff --git a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php index 737403a..76766c5 100644 --- a/backend/tests/Feature/Element/EloquentElementRepositoryTest.php +++ b/backend/tests/Feature/Element/EloquentElementRepositoryTest.php @@ -156,6 +156,45 @@ class EloquentElementRepositoryTest extends TestCase ); } + public function testItListsEveryElementForASet(): void + { + $bible = $this->createSet('Bible'); + $course = $this->createSet('Course'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $bible, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $repository->create(new CreateElementDto( + set: $course, + name: 'Foundations', + kind: 'module', + parentElement: null, + )); + + $elements = $repository->findBySet($bible); + + $this->assertSame( + ['Genesis', 'Creation'], + array_map(function ($element): string { + return $element->getName(); + }, $elements), + ); + $this->assertNull($elements[0]->getParentElement()); + $this->assertSame( + $genesis->getId(), + $elements[1]->getParentElement()?->getId(), + ); + } + private function createSet(string $name): Set { $creator = app(UserRepository::class)->create(new CreateUserDto( diff --git a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php index 820f0b9..a1cb965 100644 --- a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php +++ b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php @@ -66,6 +66,23 @@ class EloquentSetRepositoryTest extends TestCase ); } + public function test_it_finds_a_set_by_id(): void + { + $creator = $this->createUser('creator@example.com'); + $repository = app(SetRepository::class); + $createdSet = $repository->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + + $foundSet = $repository->find($createdSet->getId()); + + $this->assertNotNull($foundSet); + $this->assertSame($createdSet->getId(), $foundSet->getId()); + $this->assertSame('Bible', $foundSet->getName()); + $this->assertNull($repository->find(999)); + } + public function test_it_rejects_duplicate_set_names(): void { $creator = $this->createUser('creator@example.com'); diff --git a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php new file mode 100644 index 0000000..adc5f56 --- /dev/null +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -0,0 +1,171 @@ +createUser(); + $set = $this->createSet($user, 'Bible'); + $repository = app(ElementRepository::class); + $genesis = $repository->create(new CreateElementDto( + set: $set, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $repository->create(new CreateElementDto( + set: $set, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $creation = $repository->create(new CreateElementDto( + set: $set, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $chapter = $repository->create(new CreateElementDto( + set: $set, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $this->createSession($user); + + $response = $this->credentialedGet("/api/sets/{$set->getId()}"); + + $response->assertOk()->assertExactJson([ + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Bible', + ], + 'elements' => [ + [ + 'id' => $genesis->getId(), + 'name' => 'Genesis', + 'kind' => 'book', + 'children' => [ + [ + 'id' => $creation->getId(), + 'name' => 'Creation', + 'kind' => 'portion', + 'children' => [ + [ + 'id' => $chapter->getId(), + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'children' => [], + ], + ], + ], + ], + ], + [ + 'id' => $genesis->getId() + 1, + 'name' => 'Exodus', + 'kind' => 'book', + 'children' => [], + ], + ], + ]); + } + + public function test_it_returns_an_empty_element_layout(): void + { + $user = $this->createUser(); + $set = $this->createSet($user, 'Empty set'); + $this->createSession($user); + + $response = $this->credentialedGet("/api/sets/{$set->getId()}"); + + $response->assertOk()->assertExactJson([ + 'set' => [ + 'id' => $set->getId(), + 'name' => 'Empty set', + ], + 'elements' => [], + ]); + } + + public function test_it_returns_not_found_for_an_unknown_set(): void + { + $user = $this->createUser(); + $this->createSession($user); + + $response = $this->credentialedGet('/api/sets/999'); + + $response->assertNotFound()->assertExactJson([ + 'error' => 'set not found', + ]); + } + + public function test_it_rejects_an_unauthenticated_request(): void + { + $response = $this->getJson('/api/sets/1'); + + $response->assertStatus(401)->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + private function createUser(): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('reader@example.com'), + passwordHash: 'hashed-password', + )); + } + + private function createSet(User $user, string $name): Set + { + return app(SetRepository::class)->create(new CreateSetDto( + name: $name, + creator: $user, + )); + } + + private function createSession(User $user): void + { + $createdAt = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + app(SessionRepository::class)->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $createdAt, + expiresAt: $createdAt->modify('+7 days'), + )); + } + + private function credentialedGet(string $uri): \Illuminate\Testing\TestResponse + { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson($uri); + } +} diff --git a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php new file mode 100644 index 0000000..d17ab79 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php @@ -0,0 +1,136 @@ +create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + $course = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $creator, + )); + $genesis = $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Genesis', + kind: 'book', + parentElement: null, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Exodus', + kind: 'book', + parentElement: null, + )); + $creation = $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Creation', + kind: 'portion', + parentElement: $genesis, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Noah', + kind: 'portion', + parentElement: $genesis, + )); + $elementRepository->create(new CreateElementDto( + set: $bible, + name: 'Chapter 1', + kind: 'chapter', + parentElement: $creation, + )); + $elementRepository->create(new CreateElementDto( + set: $course, + name: 'Foundations', + kind: 'module', + parentElement: null, + )); + + $layout = (new GetSetLayout( + $setRepository, + $elementRepository, + ))->execute($bible->getId()); + + $this->assertSame('Bible', $layout->getSet()->getName()); + $this->assertSame( + ['Genesis', 'Exodus'], + $this->nodeNames($layout->getElements()), + ); + $genesisNode = $layout->getElements()[0]; + $this->assertSame( + ['Creation', 'Noah'], + $this->nodeNames($genesisNode->getChildren()), + ); + $this->assertSame( + ['Chapter 1'], + $this->nodeNames( + $genesisNode->getChildren()[0]->getChildren(), + ), + ); + } + + public function test_it_returns_an_empty_layout(): void + { + $creator = new User( + id: 7, + email: new EmailAddress('creator@example.com'), + passwordHash: 'hashed-password', + ); + $setRepository = new FakeSetRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Empty set', + creator: $creator, + )); + + $layout = (new GetSetLayout( + $setRepository, + new FakeElementRepository, + ))->execute($set->getId()); + + $this->assertSame([], $layout->getElements()); + } + + public function test_it_rejects_an_unknown_set(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('set not found'); + + (new GetSetLayout( + new FakeSetRepository, + new FakeElementRepository, + ))->execute(999); + } + + /** + * @param list<\App\Set\UseCases\GetSetLayout\ElementLayoutNode> $nodes + * @return list + */ + private function nodeNames(array $nodes): array + { + return array_map(function ($node): string { + return $node->getElement()->getName(); + }, $nodes); + } +} diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index 7c0e66c..75c441c 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -24,6 +24,13 @@ describe('guest authentication pages', () => { cy.location('search').should('include', 'redirect=/dashboard') }) + it('redirects guests away from a protected set layout', () => { + cy.visit('/sets/41') + + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/sets/41') + }) + it('shows the login form and links to signup', () => { cy.visit('/login') diff --git a/frontend/website/cypress/e2e/set-layout.cy.ts b/frontend/website/cypress/e2e/set-layout.cy.ts new file mode 100644 index 0000000..5430694 --- /dev/null +++ b/frontend/website/cypress/e2e/set-layout.cy.ts @@ -0,0 +1,183 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +const bibleLayout = { + set: { id: 41, name: 'Bible' }, + elements: [ + { + id: 1, + name: 'Genesis', + kind: 'book', + children: [ + { + id: 3, + name: 'Creation', + kind: 'portion', + children: [ + { + id: 4, + name: 'Chapter 1', + kind: 'chapter', + children: [], + }, + ], + }, + { id: 5, name: 'Noah', kind: 'portion', children: [] }, + ], + }, + { id: 2, name: 'Exodus', kind: 'book', children: [] }, + ], +} + +function interceptAuthenticatedUser(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') +} + +describe('set element layout', () => { + beforeEach(() => { + interceptAuthenticatedUser() + }) + + it('opens a set from the dashboard and shows its full hierarchy', () => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [{ id: 41, name: 'Bible' }] }, + }).as('sets') + cy.intercept('GET', '**/api/sets/41', (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ statusCode: 200, body: bibleLayout }) + }).as('layout') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + cy.contains('a', 'Bible').click() + cy.wait('@layout') + + cy.location('pathname').should('equal', '/sets/41') + cy.get('h1').should('have.text', 'Bible') + cy.contains('a', 'Back to sets').should('have.attr', 'href', '/dashboard') + cy.get('ol[aria-label="Bible element layout"] > li').then(($nodes) => { + expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ + '1', + '2', + ]) + }) + cy.get('[data-element-id="1"] > .element-node__card') + .should('contain.text', 'Genesis') + .and('contain.text', 'book') + cy.get('[data-element-id="1"] > ol > li').then(($nodes) => { + expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ + '3', + '5', + ]) + }) + cy.get('[data-element-id="3"] > ol > li') + .should('have.length', 1) + .and('have.attr', 'data-element-id', '4') + cy.contains('.element-node__card', 'Chapter 1') + .should('be.visible') + .and('contain.text', 'chapter') + }) + + it('shows loading and empty layout states', () => { + cy.intercept('GET', '**/api/sets/41', { + delay: 500, + statusCode: 200, + body: { set: { id: 41, name: 'Empty set' }, elements: [] }, + }).as('layout') + + cy.visit('/sets/41') + cy.wait('@me') + cy.get('[role="status"]').should('have.text', 'Loading set layout...') + cy.wait('@layout') + + cy.get('h1').should('have.text', 'Empty set') + cy.get('[role="status"]').should( + 'have.text', + 'This set does not have any elements yet.', + ) + }) + + it('retries after a layout request fails', () => { + let requestCount = 0 + cy.intercept('GET', '**/api/sets/41', (request) => { + requestCount += 1 + request.alias = `layout${requestCount}` + + if (requestCount === 1) { + request.reply({ statusCode: 500 }) + return + } + + request.reply({ statusCode: 200, body: bibleLayout }) + }) + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@layout1') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load this set's layout.", + ) + cy.contains('button', 'Try again').click() + cy.wait('@layout2') + + cy.get('h1').should('have.text', 'Bible') + }) + + it('distinguishes missing sets from malformed responses', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 404, + body: { error: 'set not found' }, + }).as('missing') + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@missing') + + cy.get('h1').should('have.text', 'Set not found') + cy.get('[role="alert"]').should( + 'contain.text', + "The set you're looking for is not available.", + ) + + cy.intercept('GET', '**/api/sets/42', { + statusCode: 200, + body: { set: { id: 42, name: 12 }, elements: [] }, + }).as('malformed') + cy.visit('/sets/42') + cy.wait('@malformed') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load this set's layout.", + ) + cy.contains('button', 'Try again').should('be.visible') + }) + + it('keeps a deeply nested layout within a mobile viewport', () => { + cy.viewport(390, 844) + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: bibleLayout, + }).as('layout') + + cy.visit('/sets/41') + cy.wait('@me') + cy.wait('@layout') + + cy.contains('.element-node__card', 'Chapter 1').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) + }) +}) diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts index 3872b10..4b6fc2d 100644 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -15,7 +15,7 @@ describe('sets dashboard', () => { interceptAuthenticatedUser() }) - it('shows every available set by name', () => { + it('shows every available set as a detail link', () => { cy.intercept('GET', '**/api/sets', (request) => { expect(request.headers.accept).to.equal('application/json') request.reply({ @@ -46,8 +46,13 @@ describe('sets dashboard', () => { .should('not.contain.text', '41') .and('not.contain.text', '58') .and('not.contain.text', '92') - .find('a, button') - .should('not.exist') + cy.contains('a', 'Bible').should('have.attr', 'href', '/sets/41') + cy.contains('a', 'Course').should('have.attr', 'href', '/sets/58') + cy.contains('a', 'Fitness Program').should( + 'have.attr', + 'href', + '/sets/92', + ) }) it('shows loading and empty catalog states', () => {